吸收数字员工审批历史联动
This commit is contained in:
@@ -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));
|
||||||
|
|||||||
@@ -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 ?? []);
|
||||||
|
|||||||
@@ -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' },
|
||||||
|
|||||||
@@ -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,13 +2783,97 @@ 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) => {
|
||||||
|
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 key={decision.id} className="de-task-manager-decision-card">
|
||||||
<div className="de-log-entry-meta">
|
<div className="de-log-entry-meta">
|
||||||
<strong>{decision.title}</strong>
|
<strong>{decision.title}</strong>
|
||||||
<span>{decision.status_label}</span>
|
<span>{decision.status_label}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{decision.scenario}</p>
|
<p>{decision.scenario}</p>
|
||||||
|
<div className="de-approval-summary-grid">
|
||||||
|
<span className={`de-badge ${badgeClass(approvalRiskTone(decision.risk_level))}`}>
|
||||||
|
{approvalRiskLabel(decision.risk_level)}
|
||||||
|
</span>
|
||||||
|
<span className={`de-badge ${badgeClass(approvalSlaTone(decision.sla_status))}`}>
|
||||||
|
{approvalSlaLabel(decision.sla_status)}
|
||||||
|
</span>
|
||||||
|
<span>{decision.due_at ? `截止 ${formatBeijingDateTime(decision.due_at) || decision.due_at}` : '未设置截止'}</span>
|
||||||
|
<span>{decision.comment_count ? `评论 ${decision.comment_count} 条` : '暂无评论'}</span>
|
||||||
|
<span>{decision.delegate_to ? `转交 ${decision.delegate_to}` : '未转交'}</span>
|
||||||
|
<span>{decision.last_action ? `最近 ${approvalActionLabel(decision.last_action)} · ${decision.last_actor || '未知'}` : '暂无审批动作'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="de-approval-action-fields">
|
||||||
|
<input
|
||||||
|
value={draft.comment}
|
||||||
|
placeholder="评论 / 风险原因"
|
||||||
|
onChange={(event) => updateApprovalActionDraft(decision.id, { comment: event.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
value={draft.delegateTo}
|
||||||
|
placeholder="转交对象"
|
||||||
|
onChange={(event) => updateApprovalActionDraft(decision.id, { delegateTo: event.target.value })}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={draft.dueAt}
|
||||||
|
onChange={(event) => updateApprovalActionDraft(decision.id, { dueAt: event.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="de-workbench-actions de-approval-governance-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="记录审批评论"
|
||||||
|
disabled={commentBusy}
|
||||||
|
onClick={() => { void submitApprovalAction(decision, 'comment'); }}
|
||||||
|
>
|
||||||
|
<MessageSquare size={13} />
|
||||||
|
<span>{commentBusy ? '记录中...' : '评论'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="转交审批"
|
||||||
|
disabled={delegateBusy}
|
||||||
|
onClick={() => { void submitApprovalAction(decision, 'delegate'); }}
|
||||||
|
>
|
||||||
|
<UserRoundPlus size={13} />
|
||||||
|
<span>{delegateBusy ? '转交中...' : '转交'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="标记 SLA 超时"
|
||||||
|
disabled={timeoutBusy}
|
||||||
|
onClick={() => { void submitApprovalAction(decision, 'mark_timeout'); }}
|
||||||
|
>
|
||||||
|
<TimerReset size={13} />
|
||||||
|
<span>{timeoutBusy ? '标记中...' : '超时'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="标记审批风险"
|
||||||
|
disabled={markRiskBusy}
|
||||||
|
onClick={() => { void submitApprovalAction(decision, 'mark_risk'); }}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={13} />
|
||||||
|
<span>{markRiskBusy ? '标记中...' : '风险'}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title="清除审批风险"
|
||||||
|
disabled={clearRiskBusy}
|
||||||
|
onClick={() => { void submitApprovalAction(decision, 'clear_risk'); }}
|
||||||
|
>
|
||||||
|
<ShieldCheck size={13} />
|
||||||
|
<span>{clearRiskBusy ? '清除中...' : '清除'}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<div className="de-decision-chain-note">
|
<div className="de-decision-chain-note">
|
||||||
<span className="de-badge de-badge-success">本地记录</span>
|
<span className="de-badge de-badge-success">本地记录</span>
|
||||||
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
|
<span>{isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
|
||||||
@@ -2687,7 +2895,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -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
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
|||||||
@@ -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: "需要复核",
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 : {}) });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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、同步失败、服务异常和任务完成通知,通知投影已开始闭环。
|
||||||
|
|||||||
Reference in New Issue
Block a user