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

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

@@ -1748,6 +1748,47 @@
border: 1px solid rgba(79,172,254,0.12); 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-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-detail-modal,
.de-event-history-modal { .de-event-history-modal {
width: min(760px, calc(100vw - 52px)); width: min(760px, calc(100vw - 52px));

View File

@@ -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[]> { export function getDebugConversations(): Promise<any[]> {
return apiFetch<{ conversations: any[] }>('/api/debug/conversations') return apiFetch<{ conversations: any[] }>('/api/debug/conversations')
.then((data) => data.conversations ?? []); .then((data) => data.conversations ?? []);

View File

@@ -65,6 +65,7 @@ declare global {
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>; recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>; accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
recordArtifactLifecycle?: (input: QimingclawArtifactLifecycleInput) => Promise<QimingclawArtifactLifecycleResult | null>; recordArtifactLifecycle?: (input: QimingclawArtifactLifecycleInput) => Promise<QimingclawArtifactLifecycleResult | null>;
recordApprovalAction?: (input: QimingclawApprovalActionInput) => Promise<QimingclawApprovalActionResult | null>;
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>; 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 }>; respondPermission?: (sessionId: string, permissionId: string, response: 'once' | 'always' | 'reject') => Promise<{ success?: boolean; error?: string }>;
}; };
@@ -271,6 +272,28 @@ interface QimingclawArtifactLifecycleResult {
payload: Record<string, unknown>; 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 { interface QimingclawManagedServiceActionResponse {
ok: boolean; ok: boolean;
service_id: string; 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; .find((row) => stringValue(row.plan_id) === planId || stringValue(row.remote_id) === planId) ?? null;
return { item, source: BUSINESS_VIEW_SOURCE } as T; 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)$/); const businessViewMatch = pathname.match(/^\/api\/digital-employee\/(plans|tasks|runs|events|artifacts|approvals)$/);
if (method === 'GET' && businessViewMatch) { if (method === 'GET' && businessViewMatch) {
return buildBusinessViewResponse(businessViewMatch[1] as BusinessViewKind, await readQimingclawSnapshot(), url.searchParams) as T; 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 { function syncLine(snapshot: QimingclawSnapshot | null): string {
const sync = snapshot?.sync; const sync = snapshot?.sync;
if (!sync) return '管理端同步:未连接'; if (!sync) return '管理端同步:未连接';
@@ -2491,6 +2555,7 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
return buildApprovals(snapshot).map((approval) => { return buildApprovals(snapshot).map((approval) => {
const approvalId = stringValue(approval.approval_id); const approvalId = stringValue(approval.approval_id);
const runtime = runtimeById.get(approvalId); const runtime = runtimeById.get(approvalId);
const summary = approvalActionSummary(snapshot, approvalId, approval);
const entity = businessEntityFromRefs({ const entity = businessEntityFromRefs({
plan_id: approval.plan_id, plan_id: approval.plan_id,
task_id: approval.task_id, task_id: approval.task_id,
@@ -2505,10 +2570,96 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
entity_type: entity.entity_type, entity_type: entity.entity_type,
entity_id: entity.entity_id, entity_id: entity.entity_id,
sync_status: (runtime?.syncStatus ?? stringValue(approval.sync_status)) || null, 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[] { function skillCallAuditRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
return runtimeEvents(snapshot) return runtimeEvents(snapshot)
.filter((event) => ['skill_call_allowed', 'skill_call_rejected', 'skill_call_observed'].includes(event.kind)) .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[] { function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterState): UserNotification[] {
const notifications = dedupeNotifications([ const notifications = dedupeNotifications([
...approvalNotifications(snapshot), ...approvalNotifications(snapshot),
...approvalActionNotifications(snapshot),
...syncFailureNotifications(snapshot), ...syncFailureNotifications(snapshot),
...managedServiceNotifications(snapshot), ...managedServiceNotifications(snapshot),
...taskNotifications(snapshot), ...taskNotifications(snapshot),
@@ -3098,19 +3250,24 @@ function buildNotifications(snapshot: QimingclawSnapshot | null, state: AdapterS
} }
function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] { function approvalNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
const summaries = new Map(businessApprovalRows(snapshot).map((approval) => [stringValue(approval.approval_id), approval]));
return buildApprovals(snapshot) return buildApprovals(snapshot)
.filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime') .filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime')
.map((approval) => { .map((approval) => {
const status = stringValue(approval.status) || 'pending'; const status = stringValue(approval.status) || 'pending';
const open = isDecisionOpenStatus(status); 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 { return {
notification_id: `approval:${stringValue(approval.approval_id)}`, notification_id: `approval:${stringValue(approval.approval_id)}`,
plan_id: stringValue(approval.plan_id), plan_id: stringValue(approval.plan_id),
task_id: stringValue(approval.task_id), task_id: stringValue(approval.task_id),
kind: open ? 'need_approval' : 'confirmation_resolved', kind: open ? 'need_approval' : 'confirmation_resolved',
title: open ? '审批待处理' : '审批已处理', title: warn ? '审批需要关注' : open ? '审批待处理' : '审批已处理',
body: stringValue(approval.title) || '待确认事项', body: stringValue(approval.title) || '待确认事项',
level: open ? 'action' : 'info', level: warn ? 'warn' : open ? 'action' : 'info',
status: open ? 'unread' : 'resolved', status: open ? 'unread' : 'resolved',
correlation_id: stringValue(approval.approval_id), correlation_id: stringValue(approval.approval_id),
created_at: stringValue(approval.created_at) || new Date().toISOString(), 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[] { function syncFailureNotifications(snapshot: QimingclawSnapshot | null): UserNotification[] {
return (snapshot?.sync?.recentFailures ?? []).map((failure) => ({ return (snapshot?.sync?.recentFailures ?? []).map((failure) => ({
notification_id: `sync_failure:${failure.id}`, notification_id: `sync_failure:${failure.id}`,
@@ -3233,6 +3414,7 @@ function buildWorkdayDecisions(
): DigitalEmployeeDecision[] { ): DigitalEmployeeDecision[] {
const plansById = new Map(runtimePlans(snapshot).map((plan) => [plan.id, plan])); const plansById = new Map(runtimePlans(snapshot).map((plan) => [plan.id, plan]));
const tasksById = new Map(runtimeTasks(snapshot).map((task) => [task.id, task])); 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] ?? {}; const resultByDecisionId = state.decisionResultsByDate?.[date] ?? {};
return buildApprovals(snapshot).filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime').map((approval) => { return buildApprovals(snapshot).filter((approval) => stringValue(approval.source) === 'qimingclaw-runtime').map((approval) => {
const payload = asRecord(approval.payload); const payload = asRecord(approval.payload);
@@ -3244,6 +3426,7 @@ function buildWorkdayDecisions(
const taskId = stringValue(approval.task_id); const taskId = stringValue(approval.task_id);
const plan = planId ? plansById.get(planId) : null; const plan = planId ? plansById.get(planId) : null;
const task = taskId ? tasksById.get(taskId) : null; const task = taskId ? tasksById.get(taskId) : null;
const summary = approvalSummaries.get(decisionId);
return { return {
id: decisionId, id: decisionId,
title: stringValue(approval.title) || stringValue(payload?.title) || '待确认事项', title: stringValue(approval.title) || stringValue(payload?.title) || '待确认事项',
@@ -3252,6 +3435,13 @@ function buildWorkdayDecisions(
status_label: decisionStatusLabel(status, result), status_label: decisionStatusLabel(status, result),
plan_title: plan?.title || stringValue(payload?.plan_title) || stringValue(approval.plan_id) || 'qimingclaw 本地记录', 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) || '待确认任务', 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) actions: isDecisionOpenStatus(status)
? [ ? [
{ id: 'approved', label: '同意', tone: 'primary' }, { id: 'approved', label: '同意', tone: 'primary' },

View File

@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent } 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 Avatar3D from '@/components/digital/Avatar3D';
import { basePath } from '@/lib/basePath'; import { basePath } from '@/lib/basePath';
import { import {
@@ -9,6 +9,7 @@ import {
getSchedulerJobs, getSchedulerJobs,
governanceCommand, governanceCommand,
postDigitalEmployeeWorkdayAction, postDigitalEmployeeWorkdayAction,
recordApprovalAction,
restartManagedService, restartManagedService,
} from '@/lib/api'; } from '@/lib/api';
import { isTauri } from '@/lib/tauri'; import { isTauri } from '@/lib/tauri';
@@ -36,6 +37,7 @@ type EmployeeVisualState = 'skill' | 'browser' | 'call' | 'redial' | 'evidence'
type EventStatusFilter = 'all' | 'running' | 'success' | 'danger'; type EventStatusFilter = 'all' | 'running' | 'success' | 'danger';
type TaskListFilter = 'all' | 'selected' | 'pending'; type TaskListFilter = 'all' | 'selected' | 'pending';
type TaskAgentControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task'>; type TaskAgentControlCommand = Extract<GovernanceCommandKind, 'retry_task' | 'skip_task' | 'cancel_task'>;
type ApprovalActionKind = 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk';
interface DigitalPlanItem { interface DigitalPlanItem {
id: string; id: string;
@@ -110,6 +112,18 @@ interface ManagementSyncEntitySummary {
summary?: 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 展示为“步骤”。 // 技术侧 PlanTemplate 是任务模板来源;用户侧只展示为“任务”,运行时 Task/PlanStep 展示为“步骤”。
interface EmployeeVisualStateOption { interface EmployeeVisualStateOption {
@@ -218,6 +232,48 @@ function badgeClass(tone?: string): string {
return 'de-badge-info'; 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 { function dutyCardClass(planItem: DigitalPlanItem, selected: boolean): string {
const classes = ['de-template-card-row', 'de-duty-card', 'de-duty-card--compact']; const classes = ['de-template-card-row', 'de-duty-card', 'de-duty-card--compact'];
if (!planItem.implemented) classes.push('de-duty-card--disabled'); 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 [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionNotice, setActionNotice] = useState<string | null>(null); const [actionNotice, setActionNotice] = useState<string | null>(null);
const [actionError, setActionError] = 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 [planDataError, setPlanDataError] = useState<string | null>(null);
const [selectionDirty, setSelectionDirty] = useState(false); const [selectionDirty, setSelectionDirty] = useState(false);
const [taskListFilter, setTaskListFilter] = useState<TaskListFilter>('selected'); const [taskListFilter, setTaskListFilter] = useState<TaskListFilter>('selected');
@@ -1346,6 +1403,73 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
} }
}, [selectedDate]); }, [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 ( const controlTaskAgent = useCallback(async (
agent: DigitalEmployeeTaskAgentState, agent: DigitalEmployeeTaskAgentState,
command: TaskAgentControlCommand, command: TaskAgentControlCommand,
@@ -2659,35 +2783,120 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
{(workdayProjection?.decisions ?? []).length === 0 ? ( {(workdayProjection?.decisions ?? []).length === 0 ? (
<p className="de-empty-text"></p> <p className="de-empty-text"></p>
) : ( ) : (
(workdayProjection?.decisions ?? []).map((decision) => ( (workdayProjection?.decisions ?? []).map((decision) => {
<div key={decision.id} className="de-task-manager-decision-card"> const draft = approvalActionDrafts[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT;
<div className="de-log-entry-meta"> const actionBusyPrefix = `approval-action:${decision.id}:`;
<strong>{decision.title}</strong> const commentBusy = actionBusy === `${actionBusyPrefix}comment`;
<span>{decision.status_label}</span> const delegateBusy = actionBusy === `${actionBusyPrefix}delegate`;
</div> const timeoutBusy = actionBusy === `${actionBusyPrefix}mark_timeout`;
<p>{decision.scenario}</p> const markRiskBusy = actionBusy === `${actionBusyPrefix}mark_risk`;
<div className="de-decision-chain-note"> const clearRiskBusy = actionBusy === `${actionBusyPrefix}clear_risk`;
<span className="de-badge de-badge-success"></span> return (
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span> <div key={decision.id} className="de-task-manager-decision-card">
</div> <div className="de-log-entry-meta">
<div className="de-workbench-actions"> <strong>{decision.title}</strong>
{decision.actions.map((action) => ( <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 <button
key={action.id}
type="button" type="button"
className={action.tone === 'primary' ? 'de-workbench-action-accent' : ''} title="记录审批评论"
disabled={actionBusy === `${decision.id}:${action.id}` || !isDecisionOpen(decision.status)} disabled={commentBusy}
onClick={() => { void resolveDecision(decision, action.id); }} onClick={() => { void submitApprovalAction(decision, 'comment'); }}
> >
{actionBusy === `${decision.id}:${action.id}` ? '处理中...' : action.label} <MessageSquare size={13} />
<span>{commentBusy ? '记录中...' : '评论'}</span>
</button> </button>
))} <button
<button type="button" onClick={() => onNavigate({ tab: 'missions', date: selectedDate, missionId: decision.id })}> type="button"
title="转交审批"
</button> 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> );
)) })
)} )}
</div> </div>
</aside> </aside>

View File

@@ -734,6 +734,13 @@ export interface DigitalEmployeeDecision {
status_label: string; status_label: string;
plan_title: string; plan_title: string;
task_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[]; actions: DigitalEmployeeDecisionAction[];
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,8 +16,8 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error); console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
} }
</script> </script>
<script type="module" crossorigin src="./assets/index-CtCKBhxB.js"></script> <script type="module" crossorigin src="./assets/index-CjpyhZLg.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CeppSmWE.css"> <link rel="stylesheet" crossorigin href="./assets/index-DKoke9Cq.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -4,6 +4,7 @@ import type { HandlerContext } from "@shared/types/ipc";
const mockStateService = vi.hoisted(() => ({ const mockStateService = vi.hoisted(() => ({
recordManagedServiceAction: vi.fn(), recordManagedServiceAction: vi.fn(),
recordArtifactLifecycle: vi.fn(), recordArtifactLifecycle: vi.fn(),
recordApprovalAction: vi.fn(),
})); }));
const mockArtifactAccess = vi.hoisted(() => ({ const mockArtifactAccess = vi.hoisted(() => ({
@@ -68,6 +69,7 @@ vi.mock("../services/digitalEmployee/stateService", () => ({
recordDigitalEmployeeGovernanceCommand: vi.fn(), recordDigitalEmployeeGovernanceCommand: vi.fn(),
recordDigitalEmployeeDailyReport: vi.fn(), recordDigitalEmployeeDailyReport: vi.fn(),
recordDigitalEmployeeArtifactLifecycle: mockStateService.recordArtifactLifecycle, recordDigitalEmployeeArtifactLifecycle: mockStateService.recordArtifactLifecycle,
recordDigitalEmployeeApprovalAction: mockStateService.recordApprovalAction,
recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction, recordDigitalEmployeeManagedServiceAction: mockStateService.recordManagedServiceAction,
readDigitalEmployeeRuntimeRecords: vi.fn(), readDigitalEmployeeRuntimeRecords: vi.fn(),
readDigitalEmployeeState: vi.fn(), readDigitalEmployeeState: vi.fn(),
@@ -136,6 +138,7 @@ describe("digital employee managed service actions", () => {
vi.clearAllMocks(); vi.clearAllMocks();
mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" }); mockStateService.recordManagedServiceAction.mockReturnValue({ eventId: "event-1" });
mockStateService.recordArtifactLifecycle.mockReturnValue({ eventId: "artifact-event-1", kind: "artifact_retention_marked", payload: {} }); mockStateService.recordArtifactLifecycle.mockReturnValue({ eventId: "artifact-event-1", kind: "artifact_retention_marked", payload: {} });
mockStateService.recordApprovalAction.mockReturnValue({ eventId: "approval-event-1", kind: "approval_action_recorded", payload: {} });
mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" }); mockArtifactAccess.accessDigitalEmployeeArtifact.mockResolvedValue({ ok: true, artifact_id: "artifact-1", action: "preview" });
mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" }); mockServiceManager.startFileServer.mockResolvedValue({ success: true, message: "started" });
mockServiceManager.createServiceManager.mockReturnValue({ mockServiceManager.createServiceManager.mockReturnValue({
@@ -228,4 +231,24 @@ describe("digital employee managed service actions", () => {
expect(result).toMatchObject({ eventId: "artifact-event-1", kind: "artifact_retention_marked" }); expect(result).toMatchObject({ eventId: "artifact-event-1", kind: "artifact_retention_marked" });
expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" }); expect(mockStateService.recordArtifactLifecycle).toHaveBeenCalledWith({ artifactId: "artifact-1", action: "mark_keep" });
}); });
it("registers approval action history through the digital employee IPC boundary", async () => {
const electron = await import("electron");
const { registerDigitalEmployeeHandlers } = await import("./digitalEmployeeHandlers");
registerDigitalEmployeeHandlers(createCtx());
const calls = vi.mocked(electron.ipcMain.handle).mock.calls;
const approvalCall = calls.find(([channel]) => channel === "digitalEmployee:recordApprovalAction");
expect(approvalCall).toBeTruthy();
const handler = approvalCall?.[1] as (_event: unknown, input: unknown) => Promise<unknown>;
const result = await handler({}, { approvalId: "approval-1", action: "comment", comment: "需要复核" });
expect(result).toMatchObject({ eventId: "approval-event-1", kind: "approval_action_recorded" });
expect(mockStateService.recordApprovalAction).toHaveBeenCalledWith({
approvalId: "approval-1",
action: "comment",
comment: "需要复核",
});
});
}); });

View File

@@ -22,6 +22,7 @@ import {
recordDigitalEmployeeGovernanceCommand, recordDigitalEmployeeGovernanceCommand,
recordDigitalEmployeeDailyReport, recordDigitalEmployeeDailyReport,
recordDigitalEmployeeArtifactLifecycle, recordDigitalEmployeeArtifactLifecycle,
recordDigitalEmployeeApprovalAction,
recordDigitalEmployeeManagedServiceAction, recordDigitalEmployeeManagedServiceAction,
readDigitalEmployeeRuntimeRecords, readDigitalEmployeeRuntimeRecords,
readDigitalEmployeeState, readDigitalEmployeeState,
@@ -41,6 +42,7 @@ import {
type DigitalEmployeeGovernanceCommandRecord, type DigitalEmployeeGovernanceCommandRecord,
type DigitalEmployeeDailyReportRecordInput, type DigitalEmployeeDailyReportRecordInput,
type DigitalEmployeeArtifactLifecycleInput, type DigitalEmployeeArtifactLifecycleInput,
type DigitalEmployeeApprovalActionInput,
type DigitalEmployeeMemoryUpsertInput, type DigitalEmployeeMemoryUpsertInput,
type DigitalEmployeeRouteDecisionInput, type DigitalEmployeeRouteDecisionInput,
type DigitalEmployeeServiceStatus, type DigitalEmployeeServiceStatus,
@@ -241,6 +243,13 @@ export function registerDigitalEmployeeHandlers(ctx: HandlerContext): void {
}, },
); );
ipcMain.handle(
"digitalEmployee:recordApprovalAction",
async (_, input: DigitalEmployeeApprovalActionInput) => {
return recordDigitalEmployeeApprovalAction(input ?? { action: "unknown_action" });
},
);
ipcMain.handle( ipcMain.handle(
"digitalEmployee:restartManagedService", "digitalEmployee:restartManagedService",
async (_, input: RestartManagedServiceRequest) => { async (_, input: RestartManagedServiceRequest) => {

View File

@@ -497,6 +497,70 @@ describe("digital employee state service", () => {
}); });
}); });
it("records approval action history as syncable sanitized events", async () => {
const { recordDigitalEmployeeApprovalAction } = await import("./stateService");
const longComment = `${"需要业务复核。".repeat(20)} sensitive-tail-token`;
const recorded = recordDigitalEmployeeApprovalAction({
approvalId: "approval-1",
action: "delegate",
comment: longComment,
delegateTo: "ops-lead",
delegateLabel: "运营负责人",
actor: "operator",
source: "test",
planId: "plan-1",
taskId: "task-1",
runId: "run-1",
occurredAt: "2026-06-07T08:37:00.000Z",
});
const rejected = recordDigitalEmployeeApprovalAction({
approvalId: "approval-1",
action: "delete_approval",
occurredAt: "2026-06-07T08:38:00.000Z",
});
expect(recorded?.eventId).toBe("approval-action:approval-1:delegate:2026-06-07T08-37-00-000Z");
expect(mockState.db?.events.get(recorded!.eventId)).toMatchObject({
kind: "approval_action_recorded",
message: "审批动作已记录:转交 approval-1",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
const payload = JSON.parse(mockState.db?.events.get(recorded!.eventId)?.payload ?? "{}");
expect(payload).toMatchObject({
approval_id: "approval-1",
action: "delegate",
status: "recorded",
comment_summary: {
length: longComment.length,
},
delegate_summary: {
target: "ops-lead",
label: "运营负责人",
},
actor: "operator",
source: "test",
plan_id: "plan-1",
task_id: "task-1",
run_id: "run-1",
});
expect(payload.comment_summary.preview.length).toBeLessThanOrEqual(160);
expect(JSON.stringify(payload)).not.toContain("sensitive-tail-token");
expect(mockState.db?.outbox.get(`event:insert:${recorded!.eventId}`)).toMatchObject({
entity_type: "event",
entity_id: recorded!.eventId,
operation: "insert",
status: "pending",
});
expect(rejected?.kind).toBe("approval_action_rejected");
expect(JSON.parse(mockState.db?.events.get(rejected!.eventId)?.payload ?? "{}")).toMatchObject({
action: "delete_approval",
status: "rejected",
});
});
it("records and lists route decisions as syncable events", async () => { it("records and lists route decisions as syncable events", async () => {
const { const {
listDigitalEmployeeRouteDecisions, listDigitalEmployeeRouteDecisions,

View File

@@ -263,6 +263,32 @@ export interface DigitalEmployeeArtifactLifecycleRecord {
payload: Record<string, unknown>; payload: Record<string, unknown>;
} }
export type DigitalEmployeeApprovalActionKind = "comment" | "delegate" | "mark_timeout" | "mark_risk" | "clear_risk";
export interface DigitalEmployeeApprovalActionInput {
approvalId?: string | null;
action: DigitalEmployeeApprovalActionKind | 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;
occurredAt?: string | null;
message?: string | null;
}
export interface DigitalEmployeeApprovalActionRecord {
eventId: string;
kind: "approval_action_recorded" | "approval_action_rejected";
payload: Record<string, unknown>;
}
export interface DigitalEmployeeRouteDecisionRecord { export interface DigitalEmployeeRouteDecisionRecord {
id: string; id: string;
route_kind: string; route_kind: string;
@@ -1155,6 +1181,104 @@ export function recordDigitalEmployeeArtifactLifecycle(
return { eventId, kind, payload }; return { eventId, kind, payload };
} }
export function recordDigitalEmployeeApprovalAction(
input: DigitalEmployeeApprovalActionInput,
): DigitalEmployeeApprovalActionRecord | null {
const db = getDigitalEmployeeDb();
if (!db) return null;
const now = input.occurredAt || new Date().toISOString();
const approvalId = stringValue(input.approvalId) || "unknown-approval";
const action = stringValue(input.action) || "unknown_action";
const accepted = isDigitalEmployeeApprovalAction(action);
const kind: DigitalEmployeeApprovalActionRecord["kind"] = accepted
? "approval_action_recorded"
: "approval_action_rejected";
const eventId = `approval-action:${safeId(approvalId)}:${safeId(action)}:${safeId(now)}`;
const payload: Record<string, unknown> = {
approval_id: approvalId,
action,
status: accepted ? "recorded" : "rejected",
comment_summary: sanitizeApprovalComment(input.comment),
delegate_summary: sanitizeApprovalDelegate(input.delegateTo, input.delegateLabel),
sla_summary: sanitizeApprovalSla(input.dueAt, action),
risk_summary: sanitizeApprovalRisk(input.riskLevel, action),
reason: stringValue(input.reason) || null,
actor: stringValue(input.actor) || null,
source: stringValue(input.source) || "qimingclaw-approval-action",
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
run_id: stringValue(input.runId) || null,
};
insertEvent(
{
event_id: eventId,
kind,
occurred_at: now,
message: input.message || approvalActionMessage(kind, action, approvalId),
plan_id: stringValue(input.planId) || null,
task_id: stringValue(input.taskId) || null,
},
stringValue(input.runId) || null,
payload,
);
return { eventId, kind, payload };
}
function isDigitalEmployeeApprovalAction(action: string): action is DigitalEmployeeApprovalActionKind {
return ["comment", "delegate", "mark_timeout", "mark_risk", "clear_risk"].includes(action);
}
function sanitizeApprovalComment(comment: string | null | undefined): Record<string, unknown> | null {
const text = stringValue(comment);
if (!text) return null;
return {
preview: text.slice(0, 160),
length: text.length,
};
}
function sanitizeApprovalDelegate(delegateTo: string | null | undefined, delegateLabel: string | null | undefined): Record<string, unknown> | null {
const target = stringValue(delegateTo);
const label = stringValue(delegateLabel);
if (!target && !label) return null;
return {
target: target || null,
label: label || target || null,
};
}
function sanitizeApprovalSla(dueAt: string | null | undefined, action: string): Record<string, unknown> {
const due = stringValue(dueAt);
return {
due_at: due || null,
sla_status: action === "mark_timeout" ? "timeout" : due ? "tracked" : "untracked",
};
}
function sanitizeApprovalRisk(riskLevel: string | null | undefined, action: string): Record<string, unknown> {
const explicit = stringValue(riskLevel);
const level = action === "mark_risk" ? explicit || "high" : action === "clear_risk" ? "normal" : explicit || null;
return { risk_level: level };
}
function approvalActionMessage(
kind: DigitalEmployeeApprovalActionRecord["kind"],
action: string,
approvalId: string,
): string {
if (kind === "approval_action_rejected") return `审批动作已拒绝:${approvalId}`;
const actionLabel = action === "comment"
? "评论"
: action === "delegate"
? "转交"
: action === "mark_timeout"
? "标记超时"
: action === "mark_risk"
? "标记风险"
: "清除风险";
return `审批动作已记录:${actionLabel} ${approvalId}`;
}
function isDigitalEmployeeArtifactRetentionAction(action: string): action is DigitalEmployeeArtifactRetentionAction { function isDigitalEmployeeArtifactRetentionAction(action: string): action is DigitalEmployeeArtifactRetentionAction {
return ["mark_keep", "mark_expire", "mark_review", "clear_retention"].includes(action); return ["mark_keep", "mark_expire", "mark_review", "clear_retention"].includes(action);
} }

View File

@@ -165,6 +165,9 @@ contextBridge.exposeInMainWorld("QimingClawBridge", {
async recordArtifactLifecycle(input: unknown) { async recordArtifactLifecycle(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordArtifactLifecycle", input); return ipcRenderer.invoke("digitalEmployee:recordArtifactLifecycle", input);
}, },
async recordApprovalAction(input: unknown) {
return ipcRenderer.invoke("digitalEmployee:recordApprovalAction", input);
},
async restartManagedService(serviceId: string, options?: unknown) { async restartManagedService(serviceId: string, options?: unknown) {
return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) }); return ipcRenderer.invoke("digitalEmployee:restartManagedService", { serviceId, ...(typeof options === "object" && options ? options : {}) });
}, },

View File

@@ -578,9 +578,9 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。 - 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把可操作交付视图推进到完整生命周期管理。
8. **审批 / 人工介入增强** 8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event,并可通过 embedded `/api/approval/:approvalId/timeline` 读取只读审批动作时间线,审批动作时间线已开始闭环。 - 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event;评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要和 notification 投影,审批动作历史与通知联动已开始闭环。
- 缺口:缺少评论、转交、超时、SLA、通知、审批历史 UI、风险等级和多步审批流。 - 缺口:缺少系统级桌面通知、管理端审批历史视图、审批订阅策略、风险策略下发和多步审批流。
- 建议:下一轮先补 notification 投影和审批历史 UI再做多动作审批 - 建议:下一轮先补管理端审批历史与通知订阅策略,再做多步审批和风险策略联动
9. **通知 / Inbox / 用户消息** 9. **通知 / Inbox / 用户消息**
- 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知通知投影已开始闭环。 - 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知通知投影已开始闭环。