吸收数字员工审批历史联动
This commit is contained in:
@@ -1748,6 +1748,47 @@
|
||||
border: 1px solid rgba(79,172,254,0.12);
|
||||
}
|
||||
.de-task-manager-decision-card p { margin: 0; color: #58728e; font-size: 12px; line-height: 1.48; }
|
||||
.de-approval-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: #41627f;
|
||||
font-size: 11px;
|
||||
}
|
||||
.de-approval-summary-grid > span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.de-approval-action-fields {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.9fr) minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
}
|
||||
.de-approval-action-fields input {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 32px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(79,172,254,0.18);
|
||||
background: rgba(255,255,255,0.88);
|
||||
color: #173550;
|
||||
font-size: 12px;
|
||||
}
|
||||
.de-approval-action-fields input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(79,172,254,0.48);
|
||||
box-shadow: 0 0 0 2px rgba(79,172,254,0.12);
|
||||
}
|
||||
.de-approval-governance-actions button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
.de-approval-governance-actions svg { flex-shrink: 0; }
|
||||
.de-event-detail-modal,
|
||||
.de-event-history-modal {
|
||||
width: min(760px, calc(100vw - 52px));
|
||||
|
||||
@@ -760,6 +760,17 @@ export function setArtifactRetention(
|
||||
});
|
||||
}
|
||||
|
||||
export function recordApprovalAction(
|
||||
approvalId: string,
|
||||
action: 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk',
|
||||
options: Record<string, string | null | undefined> = {},
|
||||
): Promise<any> {
|
||||
return apiFetch(`/api/digital-employee/approvals/${encodeURIComponent(approvalId)}/actions`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDebugConversations(): Promise<any[]> {
|
||||
return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
|
||||
.then((data) => data.conversations ?? []);
|
||||
|
||||
@@ -65,6 +65,7 @@ declare global {
|
||||
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
||||
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
|
||||
recordArtifactLifecycle?: (input: QimingclawArtifactLifecycleInput) => Promise<QimingclawArtifactLifecycleResult | null>;
|
||||
recordApprovalAction?: (input: QimingclawApprovalActionInput) => Promise<QimingclawApprovalActionResult | null>;
|
||||
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
|
||||
respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
|
||||
};
|
||||
@@ -271,6 +272,28 @@ interface QimingclawArtifactLifecycleResult {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface QimingclawApprovalActionInput {
|
||||
approvalId?: string | null;
|
||||
action: string;
|
||||
comment?: string | null;
|
||||
delegateTo?: string | null;
|
||||
delegateLabel?: string | null;
|
||||
dueAt?: string | null;
|
||||
riskLevel?: string | null;
|
||||
reason?: string | null;
|
||||
actor?: string | null;
|
||||
source?: string | null;
|
||||
planId?: string | null;
|
||||
taskId?: string | null;
|
||||
runId?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawApprovalActionResult {
|
||||
eventId: string;
|
||||
kind: string;
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface QimingclawManagedServiceActionResponse {
|
||||
ok: boolean;
|
||||
service_id: string;
|
||||
@@ -727,6 +750,11 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
.find((row) => stringValue(row.plan_id) === planId || stringValue(row.remote_id) === planId) ?? null;
|
||||
return { item, source: BUSINESS_VIEW_SOURCE } as T;
|
||||
}
|
||||
const approvalActionMatch = pathname.match(/^\/api\/digital-employee\/approvals\/([^/]+)\/actions$/);
|
||||
if (method === 'POST' && approvalActionMatch) {
|
||||
const approvalId = decodeURIComponent(approvalActionMatch[1] || '');
|
||||
return await handleApprovalAction(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals)$/);
|
||||
if (method === 'GET' && businessViewMatch) {
|
||||
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T;
|
||||
@@ -1485,6 +1513,42 @@ async function handleArtifactRetention(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleApprovalAction(
|
||||
approvalId: string,
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
const action = stringValue(body.action) || 'comment';
|
||||
const approval = approvalDetailRow(snapshot, approvalId);
|
||||
if (!bridge?.recordApprovalAction) {
|
||||
return { ok: false, approval_id: approvalId, action, status: 'rejected', error: 'bridge_unavailable' };
|
||||
}
|
||||
const result = await bridge.recordApprovalAction({
|
||||
approvalId,
|
||||
action,
|
||||
comment: stringValue(body.comment) || null,
|
||||
delegateTo: stringValue(body.delegate_to ?? body.delegateTo) || null,
|
||||
delegateLabel: stringValue(body.delegate_label ?? body.delegateLabel) || null,
|
||||
dueAt: stringValue(body.due_at ?? body.dueAt) || null,
|
||||
riskLevel: stringValue(body.risk_level ?? body.riskLevel) || null,
|
||||
reason: stringValue(body.reason) || null,
|
||||
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||
source: stringValue(body.source) || 'sgrobot-digital-adapter',
|
||||
planId: approval ? stringValue(approval.plan_id) || null : null,
|
||||
taskId: approval ? stringValue(approval.task_id) || null : null,
|
||||
runId: approval ? stringValue(approval.run_id) || null : null,
|
||||
});
|
||||
return {
|
||||
ok: Boolean(result && result.kind !== 'approval_action_rejected'),
|
||||
approval_id: approvalId,
|
||||
action,
|
||||
status: result?.kind === 'approval_action_rejected' ? 'rejected' : 'recorded',
|
||||
event_id: result?.eventId ?? null,
|
||||
item: approval,
|
||||
};
|
||||
}
|
||||
|
||||
function syncLine(snapshot: QimingclawSnapshot | null): string {
|
||||
const sync = snapshot?.sync;
|
||||
if (!sync) return '管理端同步:未连接';
|
||||
@@ -2491,6 +2555,7 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
return buildApprovals(snapshot).map((approval) => {
|
||||
const approvalId = stringValue(approval.approval_id);
|
||||
const runtime = runtimeById.get(approvalId);
|
||||
const summary = approvalActionSummary(snapshot, approvalId, approval);
|
||||
const entity = businessEntityFromRefs({
|
||||
plan_id: approval.plan_id,
|
||||
task_id: approval.task_id,
|
||||
@@ -2505,10 +2570,96 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
entity_type: entity.entity_type,
|
||||
entity_id: entity.entity_id,
|
||||
sync_status: (runtime?.syncStatus ?? stringValue(approval.sync_status)) || null,
|
||||
risk_level: summary.risk_level,
|
||||
sla_status: summary.sla_status,
|
||||
due_at: summary.due_at,
|
||||
last_action: summary.last_action,
|
||||
last_actor: summary.last_actor,
|
||||
comment_count: summary.comment_count,
|
||||
delegate_to: summary.delegate_to,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function approvalDetailRow(snapshot: QimingclawSnapshot | null, approvalId: string): BusinessViewRow | null {
|
||||
const row = businessApprovalRows(snapshot)
|
||||
.find((approval) => stringValue(approval.approval_id) === approvalId || stringValue(approval.remote_id) === approvalId) ?? null;
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
timeline: buildApprovalTimeline(stringValue(row.approval_id), snapshot).events,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalActionRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return runtimeEvents(snapshot)
|
||||
.filter((event) => ['approval_action_recorded', 'approval_action_rejected'].includes(event.kind))
|
||||
.map((event) => {
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
const delegate = asRecord(payload.delegate_summary);
|
||||
const comment = asRecord(payload.comment_summary);
|
||||
const sla = asRecord(payload.sla_summary);
|
||||
const risk = asRecord(payload.risk_summary);
|
||||
return {
|
||||
action_id: event.id,
|
||||
event_id: event.id,
|
||||
kind: event.kind,
|
||||
approval_id: stringValue(payload.approval_id) || null,
|
||||
action: stringValue(payload.action) || null,
|
||||
status: stringValue(payload.status) || null,
|
||||
comment_preview: stringValue(comment?.preview) || null,
|
||||
delegate_to: stringValue(delegate?.label ?? delegate?.target) || null,
|
||||
sla_status: stringValue(sla?.sla_status) || null,
|
||||
due_at: stringValue(sla?.due_at) || null,
|
||||
risk_level: stringValue(risk?.risk_level) || null,
|
||||
actor: stringValue(payload.actor) || null,
|
||||
source: stringValue(payload.source) || null,
|
||||
plan_id: (event.planId ?? stringValue(payload.plan_id)) || null,
|
||||
task_id: (event.taskId ?? stringValue(payload.task_id)) || null,
|
||||
run_id: (event.runId ?? stringValue(payload.run_id)) || null,
|
||||
message: event.message,
|
||||
occurred_at: event.occurredAt,
|
||||
sync_status: event.syncStatus ?? null,
|
||||
payload,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
|
||||
}
|
||||
|
||||
function approvalActionSummary(
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
approvalId: string,
|
||||
approval: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const actions = approvalActionRows(snapshot).filter((event) => stringValue(event.approval_id) === approvalId);
|
||||
const latest = actions[0];
|
||||
const latestRisk = actions.find((event) => stringValue(event.risk_level));
|
||||
const latestSla = actions.find((event) => {
|
||||
const status = stringValue(event.sla_status);
|
||||
return Boolean(status && status !== 'untracked');
|
||||
});
|
||||
const latestDelegate = actions.find((event) => stringValue(event.delegate_to));
|
||||
const payload = asRecord(approval.payload);
|
||||
const dueAt = stringValue(latestSla?.due_at) || stringValue(payload?.due_at ?? payload?.dueAt ?? payload?.timeout_at ?? payload?.timeoutAt);
|
||||
const slaStatus = stringValue(latestSla?.sla_status) || approvalSlaStatus(dueAt);
|
||||
return {
|
||||
risk_level: stringValue(latestRisk?.risk_level) || 'normal',
|
||||
sla_status: slaStatus,
|
||||
due_at: dueAt || null,
|
||||
last_action: stringValue(latest?.action) || null,
|
||||
last_actor: stringValue(latest?.actor) || null,
|
||||
comment_count: actions.filter((event) => stringValue(event.action) === 'comment').length,
|
||||
delegate_to: stringValue(latestDelegate?.delegate_to) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function approvalSlaStatus(dueAt: string): string {
|
||||
if (!dueAt) return 'untracked';
|
||||
const dueMs = Date.parse(dueAt);
|
||||
if (!Number.isFinite(dueMs)) return 'untracked';
|
||||
return dueMs < Date.now() ? 'timeout' : 'tracked';
|
||||
}
|
||||
|
||||
function skillCallAuditRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return runtimeEvents(snapshot)
|
||||
.filter((event) => ['skill_call_allowed', 'skill_call_rejected', 'skill_call_observed'].includes(event.kind))
|
||||
@@ -3087,6 +3238,7 @@ function buildNotificationsResponse(status: string | null, snapshot: QimingclawS
|
||||
function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterState): UserNotification[] {
|
||||
const notifications = dedupeNotifications([
|
||||
...approvalNotifications(snapshot),
|
||||
...approvalActionNotifications(snapshot),
|
||||
...syncFailureNotifications(snapshot),
|
||||
...managedServiceNotifications(snapshot),
|
||||
...taskNotifications(snapshot),
|
||||
@@ -3098,19 +3250,24 @@ function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterS
|
||||
}
|
||||
|
||||
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
|
||||
return buildApprovals(snapshot)
|
||||
.filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime')
|
||||
.map((approval) => {
|
||||
const status = stringValue(approval.status) || 'pending';
|
||||
const open = isDecisionOpenStatus(status);
|
||||
const summary = summaries.get(stringValue(approval.approval_id));
|
||||
const slaStatus = stringValue(summary?.sla_status);
|
||||
const riskLevel = stringValue(summary?.risk_level);
|
||||
const warn = open && (slaStatus === 'timeout' || riskLevel === 'high');
|
||||
return {
|
||||
notification_id: `approval:${stringValue(approval.approval_id)}`,
|
||||
plan_id: stringValue(approval.plan_id),
|
||||
task_id: stringValue(approval.task_id),
|
||||
kind: open ? 'need_approval' : 'confirmation_resolved',
|
||||
title: open ? '审批待处理' : '审批已处理',
|
||||
title: warn ? '审批需要关注' : open ? '审批待处理' : '审批已处理',
|
||||
body: stringValue(approval.title) || '待确认事项',
|
||||
level: open ? 'action' : 'info',
|
||||
level: warn ? 'warn' : open ? 'action' : 'info',
|
||||
status: open ? 'unread' : 'resolved',
|
||||
correlation_id: stringValue(approval.approval_id),
|
||||
created_at: stringValue(approval.created_at) || new Date().toISOString(),
|
||||
@@ -3118,6 +3275,30 @@ function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotific
|
||||
});
|
||||
}
|
||||
|
||||
function approvalActionNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return approvalActionRows(snapshot)
|
||||
.filter((action) => stringValue(action.kind) === 'approval_action_recorded')
|
||||
.filter((action) => ['delegate', 'mark_timeout', 'mark_risk'].includes(stringValue(action.action)))
|
||||
.map((action) => {
|
||||
const actionKind = stringValue(action.action);
|
||||
const approvalId = stringValue(action.approval_id);
|
||||
const isTimeout = actionKind === 'mark_timeout';
|
||||
const isRisk = actionKind === 'mark_risk';
|
||||
return {
|
||||
notification_id: `approval-action:${approvalId}:${actionKind}:${slugifyIdPart(stringValue(action.occurred_at))}`,
|
||||
plan_id: stringValue(action.plan_id),
|
||||
task_id: stringValue(action.task_id),
|
||||
kind: 'need_approval',
|
||||
title: isTimeout ? '审批已超时' : isRisk ? '审批已标记风险' : '审批已转交',
|
||||
body: stringValue(action.message) || approvalId,
|
||||
level: isTimeout || isRisk ? 'warn' : 'action',
|
||||
status: 'unread',
|
||||
correlation_id: approvalId,
|
||||
created_at: stringValue(action.occurred_at) || new Date().toISOString(),
|
||||
} satisfies UserNotification;
|
||||
});
|
||||
}
|
||||
|
||||
function syncFailureNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
|
||||
return (snapshot?.sync?.recentFailures ?? []).map((failure) => ({
|
||||
notification_id: `sync_failure:${failure.id}`,
|
||||
@@ -3233,6 +3414,7 @@ function buildWorkdayDecisions(
|
||||
): DigitalEmployeeDecision[] {
|
||||
const plansById = new Map(runtimePlans(snapshot).map((plan) => [plan.id, plan]));
|
||||
const tasksById = new Map(runtimeTasks(snapshot).map((task) => [task.id, task]));
|
||||
const approvalSummaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
|
||||
const resultByDecisionId = state.decisionResultsByDate?.[date] ?? {};
|
||||
return buildApprovals(snapshot).filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime').map((approval) => {
|
||||
const payload = asRecord(approval.payload);
|
||||
@@ -3244,6 +3426,7 @@ function buildWorkdayDecisions(
|
||||
const taskId = stringValue(approval.task_id);
|
||||
const plan = planId ? plansById.get(planId) : null;
|
||||
const task = taskId ? tasksById.get(taskId) : null;
|
||||
const summary = approvalSummaries.get(decisionId);
|
||||
return {
|
||||
id: decisionId,
|
||||
title: stringValue(approval.title) || stringValue(payload?.title) || '待确认事项',
|
||||
@@ -3252,6 +3435,13 @@ function buildWorkdayDecisions(
|
||||
status_label: decisionStatusLabel(status, result),
|
||||
plan_title: plan?.title || stringValue(payload?.plan_title) || stringValue(approval.plan_id) || 'qimingclaw 本地记录',
|
||||
task_title: task?.title || stringValue(payload?.task_title) || stringValue(approval.task_id) || '待确认任务',
|
||||
risk_level: stringValue(summary?.risk_level) || 'normal',
|
||||
sla_status: stringValue(summary?.sla_status) || 'untracked',
|
||||
due_at: stringValue(summary?.due_at) || null,
|
||||
last_action: stringValue(summary?.last_action) || null,
|
||||
last_actor: stringValue(summary?.last_actor) || null,
|
||||
comment_count: numberValue(summary?.comment_count) ?? 0,
|
||||
delegate_to: stringValue(summary?.delegate_to) || null,
|
||||
actions: isDecisionOpenStatus(status)
|
||||
? [
|
||||
{ id: 'approved', label: '同意', tone: 'primary' },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -734,6 +734,13 @@ export interface DigitalEmployeeDecision {
|
||||
status_label: string;
|
||||
plan_title: string;
|
||||
task_title: string;
|
||||
risk_level?: string | null;
|
||||
sla_status?: string | null;
|
||||
due_at?: string | null;
|
||||
last_action?: string | null;
|
||||
last_actor?: string | null;
|
||||
comment_count?: number | null;
|
||||
delegate_to?: string | null;
|
||||
actions: DigitalEmployeeDecisionAction[];
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user