吸收数字员工审批冲突与产物业务闭环
This commit is contained in:
@@ -3395,3 +3395,28 @@
|
||||
box-shadow: 0 4px 12px rgba(79,172,254,0.22);
|
||||
}
|
||||
.de-schedule-btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
|
||||
|
||||
/* Management business view */
|
||||
.de-segmented-tabs { display: inline-flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; padding: 4px; border: 1px solid var(--de-glass-border-lighter); border-radius: 8px; background: rgba(244,250,255,0.68); }
|
||||
.de-segmented-tabs button { min-width: 56px; height: 30px; padding: 0 10px; border: 0; border-radius: 6px; background: transparent; color: #41627f; font-size: 13px; cursor: pointer; }
|
||||
.de-segmented-tabs button.active { color: #fff; background: #2477b8; box-shadow: 0 2px 8px rgba(36,119,184,0.18); }
|
||||
.de-business-toolbar label { display: grid; gap: 4px; min-width: 0; }
|
||||
.de-business-toolbar label span { color: var(--de-text-muted); font-size: 12px; }
|
||||
.de-business-toolbar input, .de-business-toolbar select, .de-business-pagination select { width: 100%; min-width: 0; height: 32px; border: 1px solid var(--de-glass-border-lighter); border-radius: 6px; padding: 0 8px; color: var(--de-text-body); background: rgba(255,255,255,0.82); font-size: 13px; }
|
||||
.de-business-actions { display: inline-flex; justify-content: flex-end; gap: 6px; }
|
||||
.de-business-table th, .de-business-table td { padding: 9px 10px; border-bottom: 1px solid rgba(79,172,254,0.1); text-align: left; vertical-align: top; font-size: 12px; }
|
||||
.de-business-table th { color: #315576; background: rgba(235,246,255,0.78); font-weight: 700; white-space: nowrap; }
|
||||
.de-business-table td { color: var(--de-text-body); background: rgba(255,255,255,0.48); }
|
||||
.de-business-table tr.active td { background: rgba(79,172,254,0.09); }
|
||||
.de-business-table td strong { display: block; max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--de-text-primary); }
|
||||
.de-business-table td code { display: block; max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--de-text-muted); font-size: 11px; }
|
||||
.de-business-table td span { display: block; max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.de-business-json { border-top: 1px solid rgba(79,172,254,0.1); }
|
||||
.de-business-json summary { padding: 9px 10px; color: #315576; cursor: pointer; font-size: 13px; }
|
||||
.de-business-json pre { max-height: 280px; margin: 0; padding: 10px; overflow: auto; color: var(--de-text-body); background: rgba(244,250,255,0.62); font-size: 12px; line-height: 1.5; }
|
||||
.de-business-pagination { display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 12px; color: var(--de-text-muted); font-size: 12px; }
|
||||
.de-business-pagination label { display: inline-flex; align-items: center; gap: 6px; }
|
||||
@media (max-width: 960px) {
|
||||
.de-business-toolbar { grid-template-columns: repeat(2, minmax(0, 1fr)) !important; }
|
||||
.de-business-layout { grid-template-columns: 1fr !important; }
|
||||
}
|
||||
|
||||
@@ -14,6 +14,9 @@ import type {
|
||||
BrowserSession,
|
||||
ApprovalSubscriptionPreferences,
|
||||
BusinessViewResponse,
|
||||
DigitalEmployeeBusinessViewKind,
|
||||
DigitalEmployeeBusinessViewQuery,
|
||||
DigitalEmployeeBusinessViewRow,
|
||||
PlanStepBusinessRow,
|
||||
PlanStepDispatchPolicy,
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
@@ -713,6 +716,26 @@ export function getPlanStepGraph(params: Record<string, string | number | null |
|
||||
return apiFetch<BusinessViewResponse<PlanStepBusinessRow>>(`/api/digital-employee/plan-step-graph${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeBusinessView(
|
||||
kind: DigitalEmployeeBusinessViewKind,
|
||||
params: DigitalEmployeeBusinessViewQuery = {},
|
||||
): Promise<BusinessViewResponse<DigitalEmployeeBusinessViewRow>> {
|
||||
const endpoints: Record<DigitalEmployeeBusinessViewKind, string> = {
|
||||
plans: '/api/digital-employee/plans',
|
||||
tasks: '/api/digital-employee/tasks',
|
||||
runs: '/api/digital-employee/runs',
|
||||
events: '/api/digital-employee/events',
|
||||
artifacts: '/api/digital-employee/artifacts',
|
||||
approvals: '/api/digital-employee/approvals',
|
||||
};
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<BusinessViewResponse<DigitalEmployeeBusinessViewRow>>(`${endpoints[kind]}${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function markNotificationRead(id: string): Promise<boolean> {
|
||||
return apiFetch<{ ok: boolean }>(`/api/notifications/${encodeURIComponent(id)}/read`, {
|
||||
method: 'POST',
|
||||
@@ -893,6 +916,21 @@ export function accessArtifact(artifactId: string, action: 'preview' | 'download
|
||||
});
|
||||
}
|
||||
|
||||
export function recordDailyReportDelivery(body: {
|
||||
reportId: string;
|
||||
action: 'send' | 'confirm' | 'request_approval';
|
||||
status: string;
|
||||
actor?: string | null;
|
||||
endpoint?: string | null;
|
||||
remoteId?: string | null;
|
||||
message?: string | null;
|
||||
}): Promise<any> {
|
||||
return apiFetch('/api/digital-employee/daily-reports/delivery', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function getArtifactAccessEvents(params: Record<string, string | number | undefined> = {}): Promise<any> {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
@@ -923,6 +961,16 @@ export function setArtifactRetention(
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupArtifact(
|
||||
artifactId: string,
|
||||
options: Record<string, string | null | undefined> = {},
|
||||
): Promise<any> {
|
||||
return apiFetch(`/api/digital-employee/artifacts/${encodeURIComponent(artifactId)}/cleanup`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function recordApprovalAction(
|
||||
approvalId: string,
|
||||
action: 'comment' | 'delegate' | 'mark_timeout' | 'mark_risk' | 'clear_risk' | 'approve' | 'reject',
|
||||
@@ -934,6 +982,16 @@ export function recordApprovalAction(
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveApprovalConflict(
|
||||
approvalId: string,
|
||||
options: { resolution: 'keep_local' | 'accept_remote' | 'mark_reviewed'; actor?: string | null; comment?: string | null },
|
||||
): Promise<any> {
|
||||
return apiFetch(`/api/approval/${encodeURIComponent(approvalId)}/conflict/resolve`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(options),
|
||||
});
|
||||
}
|
||||
|
||||
export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }> {
|
||||
return apiFetch<{ ok: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>('/api/approval/updates/pull', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -65,6 +65,7 @@ declare global {
|
||||
pullRiskApprovalPolicy?: () => Promise<{ ok?: boolean; skipped?: boolean; error?: string | null }>;
|
||||
pullApprovalUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>;
|
||||
submitApprovalDecision?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
resolveApprovalConflict?: (input: QimingclawApprovalConflictResolutionInput) => Promise<QimingclawApprovalConflictResolutionResponse>;
|
||||
pullNotificationUpdates?: () => Promise<{ ok?: boolean; applied?: number; ignored?: number; skipped?: boolean; error?: string | null }>;
|
||||
submitNotificationAction?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; error?: string | null }>;
|
||||
showDesktopNotification?: (input: Record<string, unknown>) => Promise<{ ok?: boolean; shown?: boolean; reason?: string | null; error?: string | null }>;
|
||||
@@ -93,7 +94,9 @@ declare global {
|
||||
recordRouteInterventionResolution?: (input: QimingclawRouteInterventionResolutionInput) => Promise<RouteInterventionRecord>;
|
||||
recordGovernanceCommand?: (command: QimingclawGovernanceCommandRecord) => Promise<unknown>;
|
||||
recordDailyReport?: (input: QimingclawDailyReportRecordInput) => Promise<QimingclawDailyReportRecordResult | null>;
|
||||
recordDailyReportDelivery?: (input: QimingclawDailyReportDeliveryInput) => Promise<QimingclawDailyReportDeliveryResult | null>;
|
||||
accessArtifact?: (artifactId: string, action: 'preview' | 'download' | 'open_location', options?: { actor?: string | null; source?: string | null }) => Promise<QimingclawArtifactAccessResponse>;
|
||||
cleanupArtifact?: (artifactId: string, options?: QimingclawArtifactCleanupInput) => Promise<QimingclawArtifactCleanupResponse>;
|
||||
recordArtifactLifecycle?: (input: QimingclawArtifactLifecycleInput) => Promise<QimingclawArtifactLifecycleResult | null>;
|
||||
recordApprovalAction?: (input: QimingclawApprovalActionInput) => Promise<QimingclawApprovalActionResult | null>;
|
||||
restartManagedService?: (serviceId: string, options?: { reason?: string | null; actor?: string | null }) => Promise<QimingclawManagedServiceActionResponse>;
|
||||
@@ -274,6 +277,10 @@ interface QimingclawDailyReportRecordInput {
|
||||
artifactSources?: unknown[];
|
||||
filename: string;
|
||||
textContent: string;
|
||||
htmlFilename?: string | null;
|
||||
htmlContent?: string | null;
|
||||
pdfFilename?: string | null;
|
||||
pdfContentBase64?: string | null;
|
||||
generatedAt?: string | null;
|
||||
reportScheduleTime?: string | null;
|
||||
source?: string | null;
|
||||
@@ -286,9 +293,26 @@ interface QimingclawDailyReportRecordResult {
|
||||
taskId: string;
|
||||
runId: string;
|
||||
artifactId: string;
|
||||
htmlArtifactId: string;
|
||||
pdfArtifactId: string;
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
interface QimingclawDailyReportDeliveryInput {
|
||||
reportId: string;
|
||||
action: 'send' | 'confirm' | 'request_approval';
|
||||
status: string;
|
||||
actor?: string | null;
|
||||
endpoint?: string | null;
|
||||
remoteId?: string | null;
|
||||
message?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawDailyReportDeliveryResult {
|
||||
eventId: string;
|
||||
approvalId?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawArtifactAccessResponse {
|
||||
ok: boolean;
|
||||
artifact_id: string;
|
||||
@@ -300,6 +324,24 @@ interface QimingclawArtifactAccessResponse {
|
||||
event_id?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawArtifactCleanupInput {
|
||||
actor?: string | null;
|
||||
reason?: string | null;
|
||||
source?: string | null;
|
||||
retentionStatus?: string | null;
|
||||
lastRetentionAction?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawArtifactCleanupResponse {
|
||||
ok: boolean;
|
||||
artifact_id: string;
|
||||
status: 'deleted' | 'rejected' | 'failed';
|
||||
error?: string;
|
||||
reason_codes?: string[];
|
||||
event_id?: string | null;
|
||||
deleted_at?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawArtifactLifecycleInput {
|
||||
artifactId?: string | null;
|
||||
action: string;
|
||||
@@ -343,6 +385,23 @@ interface QimingclawApprovalActionResult {
|
||||
payload: Record<string, unknown>;
|
||||
}
|
||||
|
||||
interface QimingclawApprovalConflictResolutionInput {
|
||||
approvalId?: string | null;
|
||||
resolution?: string | null;
|
||||
actor?: string | null;
|
||||
comment?: string | null;
|
||||
}
|
||||
|
||||
interface QimingclawApprovalConflictResolutionResponse {
|
||||
ok: boolean;
|
||||
approval_id: string;
|
||||
resolution: string | null;
|
||||
status: string;
|
||||
conflict_active: boolean;
|
||||
reason_codes?: string[];
|
||||
event_id?: string | null;
|
||||
}
|
||||
|
||||
interface ApprovalWorkflowSummary {
|
||||
workflow_id: string | null;
|
||||
current_step_id: string | null;
|
||||
@@ -858,6 +917,9 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
const rows = filterDailyReportRows(dailyReportRows(await readQimingclawSnapshot()), url.searchParams);
|
||||
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
|
||||
}
|
||||
if (method === 'POST' && pathname === '/api/digital-employee/daily-reports/delivery') {
|
||||
return await handleDailyReportDelivery(parseJsonBody<QimingclawDailyReportDeliveryInput>(options.body)) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/digital-employee/skill-call-audits') {
|
||||
const rows = filterSkillCallAuditRows(skillCallAuditRows(await readQimingclawSnapshot()), url.searchParams);
|
||||
return { ...paginateBusinessRows(rows, url.searchParams), source: BUSINESS_VIEW_SOURCE } as T;
|
||||
@@ -876,6 +938,11 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
const artifactId = decodeURIComponent(artifactRetentionMatch[1] || '');
|
||||
return await handleArtifactRetention(artifactId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
const artifactCleanupMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/cleanup$/);
|
||||
if (method === 'POST' && artifactCleanupMatch) {
|
||||
const artifactId = decodeURIComponent(artifactCleanupMatch[1] || '');
|
||||
return await handleArtifactCleanup(artifactId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
const artifactVersionsMatch = pathname.match(/^\/api\/digital-employee\/artifacts\/([^/]+)\/versions$/);
|
||||
if (method === 'GET' && artifactVersionsMatch) {
|
||||
const artifactId = decodeURIComponent(artifactVersionsMatch[1] || '');
|
||||
@@ -901,6 +968,11 @@ export async function handleQimingclawDigitalApi<T>(
|
||||
const approvalId = decodeURIComponent(approvalActionMatch[1] || '');
|
||||
return await handleApprovalAction(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
const approvalConflictResolveMatch = pathname.match(/^\/api\/approval\/([^/]+)\/conflict\/resolve$/);
|
||||
if (method === 'POST' && approvalConflictResolveMatch) {
|
||||
const approvalId = decodeURIComponent(approvalConflictResolveMatch[1] || '');
|
||||
return await handleApprovalConflictResolution(approvalId, parseOptionalJsonBody(options.body), await readQimingclawSnapshot()) as T;
|
||||
}
|
||||
if (method === 'GET' && pathname === '/api/approval/subscriptions') {
|
||||
return approvalSubscriptionPreferences(notificationAdapterState(await readQimingclawSnapshot())) as T;
|
||||
}
|
||||
@@ -2080,6 +2152,52 @@ async function handleArtifactRetention(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleArtifactCleanup(
|
||||
artifactId: string,
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
const artifact = artifactDetailRow(snapshot, artifactId);
|
||||
const riskPolicy = await evaluateBridgeRiskPolicy({
|
||||
actionKind: 'artifact.cleanup.delete_local_file',
|
||||
actionLabel: '清理产物本地文件',
|
||||
riskLevel: 'high',
|
||||
reasonCodes: ['artifact_cleanup'],
|
||||
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||
source: 'sgrobot-digital-adapter',
|
||||
planId: artifact ? stringValue(artifact.plan_id) || null : null,
|
||||
taskId: artifact ? stringValue(artifact.task_id) || null : null,
|
||||
runId: artifact ? stringValue(artifact.run_id) || null : null,
|
||||
correlationId: `artifact:${artifactId}:cleanup`,
|
||||
payload: { artifact_id: artifactId, retention_status: stringValue(artifact?.retention_status) || null },
|
||||
});
|
||||
const blocked = riskPolicyBlockedResponse(riskPolicy);
|
||||
if (blocked) return { ...blocked, artifact_id: artifactId, status: 'rejected', item: artifact };
|
||||
if (!bridge?.cleanupArtifact) {
|
||||
return { ok: false, artifact_id: artifactId, status: 'rejected', error: 'bridge_unavailable', reason_codes: ['bridge_unavailable'], item: artifact };
|
||||
}
|
||||
try {
|
||||
const result = await bridge.cleanupArtifact(artifactId, {
|
||||
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||
source: stringValue(body.source) || 'sgrobot-digital-adapter',
|
||||
reason: stringValue(body.reason) || 'digital_employee_artifact_cleanup',
|
||||
retentionStatus: stringValue(body.retention_status ?? body.retentionStatus) || stringValue(artifact?.retention_status) || null,
|
||||
lastRetentionAction: stringValue(body.last_retention_action ?? body.lastRetentionAction) || stringValue(artifact?.last_retention_action) || null,
|
||||
});
|
||||
return { ...result, item: artifact };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
artifact_id: artifactId,
|
||||
status: 'failed',
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
reason_codes: ['artifact_cleanup_failed'],
|
||||
item: artifact,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleApprovalAction(
|
||||
approvalId: string,
|
||||
body: Record<string, unknown>,
|
||||
@@ -2131,6 +2249,47 @@ async function handleApprovalAction(
|
||||
};
|
||||
}
|
||||
|
||||
async function handleApprovalConflictResolution(
|
||||
approvalId: string,
|
||||
body: Record<string, unknown>,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const bridge = window.QimingClawBridge?.digital;
|
||||
const resolution = stringValue(body.resolution) || 'mark_reviewed';
|
||||
const approval = approvalDetailRow(snapshot, approvalId);
|
||||
const riskPolicy = await evaluateBridgeRiskPolicy({
|
||||
actionKind: `approval.conflict.resolve.${resolution}`,
|
||||
actionLabel: `审批冲突裁决:${resolution}`,
|
||||
riskLevel: resolution === 'accept_remote' ? 'medium' : 'normal',
|
||||
reasonCodes: ['approval_conflict_resolution'],
|
||||
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||
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,
|
||||
correlationId: `approval:${approvalId}:conflict:${resolution}`,
|
||||
payload: { approval_id: approvalId, resolution },
|
||||
});
|
||||
const blocked = riskPolicyBlockedResponse(riskPolicy);
|
||||
if (blocked) return { ...blocked, approval_id: approvalId, resolution, item: approval };
|
||||
if (!bridge?.resolveApprovalConflict) {
|
||||
return { ok: false, approval_id: approvalId, resolution, status: 'rejected', error: 'bridge_unavailable', reason_codes: ['bridge_unavailable'], item: approval };
|
||||
}
|
||||
const result = await bridge.resolveApprovalConflict({
|
||||
approvalId,
|
||||
resolution,
|
||||
actor: stringValue(body.actor) || 'digital_employee_ui',
|
||||
comment: stringValue(body.comment) || null,
|
||||
});
|
||||
return {
|
||||
...result,
|
||||
ok: result?.ok === true,
|
||||
approval_id: result?.approval_id || approvalId,
|
||||
resolution: result?.resolution ?? resolution,
|
||||
item: approval,
|
||||
};
|
||||
}
|
||||
|
||||
function syncLine(snapshot: QimingclawSnapshot | null): string {
|
||||
const sync = snapshot?.sync;
|
||||
if (!sync) return '管理端同步:未连接';
|
||||
@@ -3632,6 +3791,7 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
fallback_id: artifact.subject_id ?? artifactId,
|
||||
});
|
||||
const lifecycle = artifactLifecycleSummary(snapshot, artifactId);
|
||||
const cleanup = artifactCleanupSummary(snapshot, artifactId, artifact);
|
||||
const version = artifactVersionMeta(artifacts, artifact);
|
||||
const delivery = artifactDeliverySummary(artifact);
|
||||
return {
|
||||
@@ -3647,6 +3807,10 @@ function businessArtifactRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
retention_status: lifecycle.retention_status,
|
||||
retention_until: lifecycle.retention_until,
|
||||
last_retention_action: lifecycle.last_retention_action,
|
||||
cleanup_status: cleanup.cleanup_status,
|
||||
deleted_at: cleanup.deleted_at,
|
||||
cleanup_reason: cleanup.cleanup_reason,
|
||||
cleanup_event_id: cleanup.cleanup_event_id,
|
||||
last_access_status: stringValue(asRecord(artifact.access)?.last_access_status) || null,
|
||||
delivery_summary: delivery,
|
||||
};
|
||||
@@ -3661,6 +3825,7 @@ function artifactDetailRow(snapshot: QimingclawSnapshot | null, artifactId: stri
|
||||
...row,
|
||||
versions: artifactVersionRows(snapshot, stringValue(row.artifact_id)),
|
||||
lifecycle_events: artifactLifecycleRows(snapshot).filter((event) => stringValue(event.artifact_id) === stringValue(row.artifact_id)).slice(0, 20),
|
||||
cleanup_events: artifactCleanupRows(snapshot).filter((event) => stringValue(event.artifact_id) === stringValue(row.artifact_id)).slice(0, 20),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3730,6 +3895,10 @@ function businessApprovalRows(snapshot: QimingclawSnapshot | null): BusinessView
|
||||
approval_conflict_detected_at: conflict.detected_at,
|
||||
approval_conflict_local_status: conflict.local_status,
|
||||
approval_conflict_remote_current_step_id: conflict.remote_current_step_id,
|
||||
approval_conflict_resolved_at: conflict.resolved_at,
|
||||
approval_conflict_resolution: conflict.resolution,
|
||||
approval_conflict_resolution_actor: conflict.resolution_actor,
|
||||
approval_conflict_resolution_event_id: conflict.resolution_event_id,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -3873,6 +4042,7 @@ function approvalWorkflowSummary(approval: Record<string, unknown>): ApprovalWor
|
||||
function approvalConflictSummary(approval: Record<string, unknown>): Record<string, unknown> {
|
||||
const payload = asRecord(approval.payload) ?? {};
|
||||
const conflict = asRecord(payload.conflict) ?? {};
|
||||
const resolution = asRecord(conflict.resolution) ?? {};
|
||||
return {
|
||||
active: conflict.active === true,
|
||||
reason: stringValue(conflict.reason) || null,
|
||||
@@ -3880,6 +4050,10 @@ function approvalConflictSummary(approval: Record<string, unknown>): Record<stri
|
||||
detected_at: stringValue(conflict.detected_at ?? conflict.detectedAt) || null,
|
||||
local_status: stringValue(conflict.local_status ?? conflict.localStatus) || null,
|
||||
remote_current_step_id: stringValue(conflict.remote_current_step_id ?? conflict.remoteCurrentStepId) || null,
|
||||
resolved_at: stringValue(resolution.resolved_at ?? resolution.resolvedAt) || null,
|
||||
resolution: stringValue(resolution.resolution) || null,
|
||||
resolution_actor: stringValue(resolution.actor) || null,
|
||||
resolution_event_id: stringValue(resolution.event_id ?? resolution.eventId) || null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4023,6 +4197,47 @@ function artifactLifecycleRows(snapshot: QimingclawSnapshot | null): BusinessVie
|
||||
.sort((left, right) => stringValue(right.occurred_at).localeCompare(stringValue(left.occurred_at)));
|
||||
}
|
||||
|
||||
function artifactCleanupRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return runtimeEvents(snapshot)
|
||||
.filter((event) => ['artifact_cleanup_executed', 'artifact_cleanup_rejected', 'artifact_cleanup_failed'].includes(event.kind))
|
||||
.map((event) => {
|
||||
const payload = asRecord(event.payload) ?? {};
|
||||
return {
|
||||
cleanup_event_id: event.id,
|
||||
event_id: event.id,
|
||||
kind: event.kind,
|
||||
artifact_id: stringValue(payload.artifact_id) || null,
|
||||
status: stringValue(payload.status) || null,
|
||||
cleanup_status: stringValue(payload.status) || null,
|
||||
cleanup_reason: stringValue(payload.reason) || null,
|
||||
reason_codes: uniqueStrings(arrayValue(payload.reason_codes ?? payload.reasonCodes).map(stringValue)),
|
||||
actor: stringValue(payload.actor) || null,
|
||||
source: stringValue(payload.source) || null,
|
||||
deleted_at: stringValue(payload.deleted_at) || 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 artifactCleanupSummary(snapshot: QimingclawSnapshot | null, artifactId: string, artifact?: ArtifactEntry): Record<string, unknown> {
|
||||
const payloadCleanup = asRecord(asRecord(artifact?.payload)?.cleanup) ?? {};
|
||||
const latest = artifactCleanupRows(snapshot)
|
||||
.find((event) => stringValue(event.artifact_id) === artifactId) ?? null;
|
||||
return {
|
||||
cleanup_status: stringValue(latest?.cleanup_status) || stringValue(payloadCleanup.status) || null,
|
||||
deleted_at: stringValue(latest?.deleted_at) || stringValue(payloadCleanup.deleted_at) || null,
|
||||
cleanup_reason: stringValue(latest?.cleanup_reason) || stringValue(payloadCleanup.cleanup_reason) || null,
|
||||
cleanup_event_id: stringValue(latest?.cleanup_event_id) || stringValue(payloadCleanup.event_id) || null,
|
||||
};
|
||||
}
|
||||
|
||||
function artifactLifecycleSummary(snapshot: QimingclawSnapshot | null, artifactId: string): Record<string, unknown> {
|
||||
const latest = artifactLifecycleRows(snapshot)
|
||||
.find((event) => stringValue(event.artifact_id) === artifactId && stringValue(event.kind) === 'artifact_retention_marked');
|
||||
@@ -4107,7 +4322,7 @@ function artifactVersionGroupKey(artifact: Record<string, unknown>): string {
|
||||
|
||||
function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[] {
|
||||
return buildArtifacts(snapshot)
|
||||
.filter(isDailyReportArtifact)
|
||||
.filter(isDailyReportSummaryArtifact)
|
||||
.map((artifact) => {
|
||||
const payload = asRecord(artifact.payload) ?? {};
|
||||
const artifactId = stringValue(artifact.artifact_id) || stringValue(payload.artifact_id) || stringValue(artifact.subject_id);
|
||||
@@ -4126,8 +4341,13 @@ function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[]
|
||||
detail_lines: arrayValue(payload.detail_lines),
|
||||
artifact_sources: arrayValue(payload.artifact_sources),
|
||||
filename: stringValue(payload.filename) || stringValue(artifact.name) || dailyReportFilename(date),
|
||||
html_artifact_id: stringValue(payload.html_artifact_id) || `${reportId}:artifact:html`,
|
||||
pdf_artifact_id: stringValue(payload.pdf_artifact_id) || `${reportId}:artifact:pdf`,
|
||||
html_filename: stringValue(payload.html_filename) || dailyReportHtmlFilename(date),
|
||||
pdf_filename: stringValue(payload.pdf_filename) || dailyReportPdfFilename(date),
|
||||
format: stringValue(payload.format) || 'text',
|
||||
text_content: stringValue(payload.text_content) || stringValue(artifact.value),
|
||||
html_content: stringValue(payload.html_content) || '',
|
||||
generated_at: generatedAt,
|
||||
sync_status: stringValue(artifact.sync_status) || null,
|
||||
entity_type: 'artifact',
|
||||
@@ -4143,6 +4363,12 @@ function dailyReportRows(snapshot: QimingclawSnapshot | null): BusinessViewRow[]
|
||||
.sort((left, right) => stringValue(right.generated_at).localeCompare(stringValue(left.generated_at)));
|
||||
}
|
||||
|
||||
function isDailyReportSummaryArtifact(artifact: ArtifactEntry): boolean {
|
||||
const payload = asRecord(artifact.payload) ?? {};
|
||||
const format = stringValue(payload.format) || 'text';
|
||||
return stringValue(artifact.kind) === 'daily_report' && format !== 'html' && format !== 'pdf';
|
||||
}
|
||||
|
||||
function isDailyReportArtifact(artifact: ArtifactEntry): boolean {
|
||||
const payload = asRecord(artifact.payload) ?? {};
|
||||
return stringValue(artifact.kind) === 'daily_report'
|
||||
@@ -5310,6 +5536,8 @@ function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: Qimingclaw
|
||||
const formalRuntimeArtifact = runtimeArtifacts(snapshot).some((runtime) => runtime.id === artifactId || runtime.remoteId === artifactId);
|
||||
const previewable = formalRuntimeArtifact && (hasDailyText || hasLocalUri);
|
||||
const lifecycle = artifactLifecycleSummary(snapshot, artifactId);
|
||||
const cleanup = artifactCleanupSummary(snapshot, artifactId, artifact);
|
||||
const deleted = stringValue(cleanup.cleanup_status) === 'deleted';
|
||||
const version = artifactVersionMeta(artifacts, artifact);
|
||||
return {
|
||||
...artifact,
|
||||
@@ -5319,11 +5547,15 @@ function withArtifactAccessSummary(artifact: ArtifactEntry, snapshot: Qimingclaw
|
||||
retention_status: lifecycle.retention_status,
|
||||
retention_until: lifecycle.retention_until,
|
||||
last_retention_action: lifecycle.last_retention_action,
|
||||
cleanup_status: stringValue(cleanup.cleanup_status) || null,
|
||||
deleted_at: stringValue(cleanup.deleted_at) || null,
|
||||
cleanup_reason: stringValue(cleanup.cleanup_reason) || null,
|
||||
cleanup_event_id: stringValue(cleanup.cleanup_event_id) || null,
|
||||
delivery_summary: artifactDeliverySummary(artifact),
|
||||
access: {
|
||||
previewable,
|
||||
downloadable: formalRuntimeArtifact && hasLocalUri,
|
||||
openable: formalRuntimeArtifact && hasLocalUri,
|
||||
previewable: !deleted && previewable,
|
||||
downloadable: !deleted && formalRuntimeArtifact && hasLocalUri,
|
||||
openable: !deleted && formalRuntimeArtifact && hasLocalUri,
|
||||
access_policy: formalRuntimeArtifact ? 'qimingclaw-local-artifact-policy' : 'runtime-projection-readonly',
|
||||
last_access_status: stringValue(lastAccess?.status) || null,
|
||||
},
|
||||
@@ -5875,9 +6107,13 @@ function buildDailyReportProjection(input: {
|
||||
const detailLines = input.events.map((event) => `${event.time} ${event.task_title}:${event.detail}`);
|
||||
const reportId = input.generatedAt ? dailyReportId(input.date, input.generatedAt) : null;
|
||||
const downloadFilename = dailyReportFilename(input.date);
|
||||
const htmlFilename = dailyReportHtmlFilename(input.date);
|
||||
const pdfFilename = dailyReportPdfFilename(input.date);
|
||||
const baseReport: DigitalEmployeeDailyReport = {
|
||||
report_id: reportId,
|
||||
artifact_id: reportId ? `${reportId}:artifact:text` : null,
|
||||
html_artifact_id: reportId ? `${reportId}:artifact:html` : null,
|
||||
pdf_artifact_id: reportId ? `${reportId}:artifact:pdf` : null,
|
||||
event_id: reportId ? `${reportId}:event:generated` : null,
|
||||
date: input.date,
|
||||
title,
|
||||
@@ -5893,15 +6129,20 @@ function buildDailyReportProjection(input: {
|
||||
detail_lines: detailLines,
|
||||
artifact_sources: input.artifactSources,
|
||||
download_filename: downloadFilename,
|
||||
html_filename: htmlFilename,
|
||||
pdf_filename: pdfFilename,
|
||||
text_content: null,
|
||||
html_content: null,
|
||||
pdf_available: Boolean(input.generatedAt),
|
||||
pdf_url: null,
|
||||
};
|
||||
const textContent = input.generatedAt
|
||||
? buildDailyReportText(baseReport, input.runDetails, input.artifactSources)
|
||||
: null;
|
||||
return {
|
||||
...baseReport,
|
||||
text_content: input.generatedAt
|
||||
? buildDailyReportText(baseReport, input.runDetails, input.artifactSources)
|
||||
: null,
|
||||
text_content: textContent,
|
||||
html_content: textContent ? buildDailyReportHtml(baseReport, textContent) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5950,6 +6191,59 @@ function dailyReportFilename(date: string): string {
|
||||
return `qimingclaw-digital-report-${date}.txt`;
|
||||
}
|
||||
|
||||
function dailyReportHtmlFilename(date: string): string {
|
||||
return `qimingclaw-digital-report-${date}.html`;
|
||||
}
|
||||
|
||||
function dailyReportPdfFilename(date: string): string {
|
||||
return `qimingclaw-digital-report-${date}.pdf`;
|
||||
}
|
||||
|
||||
function buildDailyReportHtml(report: DigitalEmployeeDailyReport, textContent: string): string {
|
||||
const body = textContent.split('\n').map((line) => {
|
||||
if (line.startsWith('## ')) return `<h2>${escapeHtml(line.slice(3))}</h2>`;
|
||||
if (line.startsWith('- ')) return `<li>${escapeHtml(line.slice(2))}</li>`;
|
||||
if (!line.trim()) return '';
|
||||
return `<p>${escapeHtml(line)}</p>`;
|
||||
}).join('\n');
|
||||
return `<!doctype html><html><head><meta charset="utf-8"><title>${escapeHtml(report.title)}</title><style>body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;line-height:1.65;padding:32px;color:#172033;}article{max-width:860px;margin:0 auto;}h1{font-size:28px;}h2{font-size:18px;margin-top:24px;}li{margin:4px 0;}</style></head><body><article><h1>${escapeHtml(report.title)}</h1>${body}</article></body></html>`;
|
||||
}
|
||||
|
||||
function buildDailyReportPdfBase64(report: DigitalEmployeeDailyReport, textContent: string): string {
|
||||
const safeText = `${report.title}\n\n${textContent}`.replace(/[^\x20-\x7E\n]/g, '?').slice(0, 3600);
|
||||
const lines = safeText.split('\n').slice(0, 48);
|
||||
const commands = lines.map((line, index) => `BT /F1 11 Tf 48 ${760 - index * 14} Td (${pdfEscape(line)}) Tj ET`).join('\n');
|
||||
const stream = commands || 'BT /F1 11 Tf 48 760 Td (Daily Report) Tj ET';
|
||||
const objects = [
|
||||
'1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj',
|
||||
'2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj',
|
||||
'3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 4 0 R >> >> /Contents 5 0 R >> endobj',
|
||||
'4 0 obj << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >> endobj',
|
||||
`5 0 obj << /Length ${stream.length} >> stream\n${stream}\nendstream endobj`,
|
||||
];
|
||||
let offset = '%PDF-1.4\n'.length;
|
||||
const xref = ['0000000000 65535 f '];
|
||||
for (const object of objects) {
|
||||
xref.push(`${String(offset).padStart(10, '0')} 00000 n `);
|
||||
offset += object.length + 1;
|
||||
}
|
||||
const pdf = `%PDF-1.4\n${objects.join('\n')}\nxref\n0 ${objects.length + 1}\n${xref.join('\n')}\ntrailer << /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${offset}\n%%EOF`;
|
||||
return window.btoa(pdf);
|
||||
}
|
||||
|
||||
function pdfEscape(value: string): string {
|
||||
return value.replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function dailyReportId(date: string, generatedAt: string): string {
|
||||
return `daily-report:${date}:${slugifyIdPart(generatedAt)}`;
|
||||
}
|
||||
@@ -5971,6 +6265,10 @@ function reportArtifactSources(artifacts: ArtifactEntry[]) {
|
||||
retention_status: stringValue(artifact.retention_status) || null,
|
||||
retention_until: stringValue(artifact.retention_until) || null,
|
||||
last_retention_action: stringValue(artifact.last_retention_action) || null,
|
||||
cleanup_status: stringValue(artifact.cleanup_status) || null,
|
||||
deleted_at: stringValue(artifact.deleted_at) || null,
|
||||
cleanup_reason: stringValue(artifact.cleanup_reason) || null,
|
||||
cleanup_event_id: stringValue(artifact.cleanup_event_id) || null,
|
||||
file_size: typeof artifact.file_size === 'number' ? artifact.file_size : null,
|
||||
source_tool: stringValue(artifact.source_tool) || null,
|
||||
source_label: stringValue(artifact.source_label) || null,
|
||||
@@ -6064,6 +6362,10 @@ async function recordDailyReportArtifact(report: DigitalEmployeeDailyReport): Pr
|
||||
artifactSources: report.artifact_sources ?? [],
|
||||
filename: report.download_filename || dailyReportFilename(report.date),
|
||||
textContent: report.text_content,
|
||||
htmlFilename: report.html_filename || dailyReportHtmlFilename(report.date),
|
||||
htmlContent: report.html_content || buildDailyReportHtml(report, report.text_content),
|
||||
pdfFilename: report.pdf_filename || dailyReportPdfFilename(report.date),
|
||||
pdfContentBase64: buildDailyReportPdfBase64(report, report.text_content),
|
||||
generatedAt: report.generated_at,
|
||||
reportScheduleTime: report.report_schedule_time,
|
||||
source: report.source ?? 'qimingclaw',
|
||||
@@ -6074,6 +6376,23 @@ async function recordDailyReportArtifact(report: DigitalEmployeeDailyReport): Pr
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDailyReportDelivery(input: QimingclawDailyReportDeliveryInput): Promise<QimingclawDailyReportDeliveryResult> {
|
||||
const reportId = stringValue(input.reportId);
|
||||
if (!reportId) return { eventId: '' };
|
||||
const recordDelivery = window.QimingClawBridge?.digital?.recordDailyReportDelivery;
|
||||
const payload: QimingclawDailyReportDeliveryInput = {
|
||||
reportId,
|
||||
action: input.action,
|
||||
status: input.status,
|
||||
actor: input.actor ?? 'digital_employee_operator',
|
||||
endpoint: input.endpoint ?? 'management-console',
|
||||
remoteId: input.remoteId ?? null,
|
||||
message: input.message ?? null,
|
||||
};
|
||||
if (!recordDelivery) return { eventId: '' };
|
||||
return await recordDelivery(payload) ?? { eventId: '' };
|
||||
}
|
||||
|
||||
async function respondAcpPermissionForDecision(
|
||||
body: DigitalEmployeeWorkdayActionRequest,
|
||||
snapshot: QimingclawSnapshot | null,
|
||||
|
||||
@@ -8,7 +8,7 @@ function App() {
|
||||
return (
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="/digital" element={<DigitalEmployeeShell />} />
|
||||
<Route path="/digital/*" element={<DigitalEmployeeShell />} />
|
||||
<Route path="*" element={<Navigate to="/digital" replace />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { Eye, RefreshCw, Search, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeBusinessView } from '@/lib/api';
|
||||
import type {
|
||||
DigitalEmployeeBusinessViewKind,
|
||||
DigitalEmployeeBusinessViewQuery,
|
||||
DigitalEmployeeBusinessViewRow,
|
||||
} from '@/types/api';
|
||||
|
||||
const VIEW_OPTIONS: Array<{ id: DigitalEmployeeBusinessViewKind; label: string }> = [
|
||||
{ id: 'plans', label: '计划' },
|
||||
{ id: 'tasks', label: '任务' },
|
||||
{ id: 'runs', label: '运行' },
|
||||
{ id: 'events', label: '事件' },
|
||||
{ id: 'artifacts', label: '产物' },
|
||||
{ id: 'approvals', label: '审批' },
|
||||
];
|
||||
|
||||
const pageStyle: CSSProperties = {
|
||||
padding: '14px 18px',
|
||||
};
|
||||
|
||||
const toolbarStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(180px, 1fr) repeat(4, minmax(120px, 160px)) auto',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const contentStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1fr) minmax(280px, 360px)',
|
||||
gap: 12,
|
||||
alignItems: 'start',
|
||||
};
|
||||
|
||||
const tableWrapStyle: CSSProperties = {
|
||||
overflow: 'auto',
|
||||
border: '1px solid var(--de-glass-border-lighter)',
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const tableStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
minWidth: 840,
|
||||
borderCollapse: 'collapse',
|
||||
};
|
||||
|
||||
const detailStyle: CSSProperties = {
|
||||
border: '1px solid var(--de-glass-border-lighter)',
|
||||
borderRadius: 8,
|
||||
background: 'rgba(255,255,255,0.72)',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
|
||||
const emptyDetailStyle: CSSProperties = {
|
||||
padding: 18,
|
||||
color: 'var(--de-text-muted)',
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
type FilterState = {
|
||||
entity: string;
|
||||
status: string;
|
||||
syncStatus: string;
|
||||
updatedFrom: string;
|
||||
updatedTo: string;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
entity: '',
|
||||
status: '',
|
||||
syncStatus: '',
|
||||
updatedFrom: '',
|
||||
updatedTo: '',
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
function normalizeKind(value?: string | null): DigitalEmployeeBusinessViewKind {
|
||||
return VIEW_OPTIONS.some((item) => item.id === value) ? value as DigitalEmployeeBusinessViewKind : 'plans';
|
||||
}
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function rowEntityId(row: DigitalEmployeeBusinessViewRow): string {
|
||||
return displayText(row.entity_id || row.plan_id || row.task_id || row.run_id || row.event_id || row.artifact_id || row.approval_id, 'unknown');
|
||||
}
|
||||
|
||||
function rowTitle(row: DigitalEmployeeBusinessViewRow): string {
|
||||
return displayText(row.title || row.summary || row.kind || rowEntityId(row), '未命名业务对象');
|
||||
}
|
||||
|
||||
function rowTime(row: DigitalEmployeeBusinessViewRow): string {
|
||||
return displayText(row.updated_at || row.occurred_at || row.finished_at || row.started_at || row.created_at);
|
||||
}
|
||||
|
||||
function kindExtra(row: DigitalEmployeeBusinessViewRow, kind: DigitalEmployeeBusinessViewKind): string {
|
||||
if (kind === 'plans') return `任务 ${displayText(row.task_count, '0')} / 运行 ${displayText(row.run_count, '0')}`;
|
||||
if (kind === 'runs') return displayText(row.last_event_message || row.duration_ms, '无最近事件');
|
||||
if (kind === 'events') return displayText(row.kind, 'event');
|
||||
if (kind === 'artifacts') return displayText(row.cleanup_status || row.deleted_at || row.uri || row.kind, 'artifact');
|
||||
if (kind === 'approvals') return displayText(row.approval_conflict_resolution || row.approval_conflict_resolved_at || row.decision || row.approval_conflict_reason || row.kind, 'approval');
|
||||
return displayText(row.assigned_skill_id || row.operation || row.kind);
|
||||
}
|
||||
|
||||
function queryFromFilters(filters: FilterState, pageNo: number): DigitalEmployeeBusinessViewQuery {
|
||||
return {
|
||||
entity_id: filters.entity.trim() || null,
|
||||
status: filters.status.trim() || null,
|
||||
sync_status: filters.syncStatus.trim() || null,
|
||||
updated_from: filters.updatedFrom || null,
|
||||
updated_to: filters.updatedTo || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
function statusClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (['failed', 'error', 'rejected', 'conflict'].some((key) => text.includes(key))) return 'de-badge de-badge-danger';
|
||||
if (['pending', 'running', 'syncing', 'ready'].some((key) => text.includes(key))) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | null; onClose: () => void }) {
|
||||
if (!row) {
|
||||
return <aside style={detailStyle}><div style={emptyDetailStyle}>选择一行业务对象查看详情。</div></aside>;
|
||||
}
|
||||
const jsonPayload = row.business_view || row.payload ? {
|
||||
business_view: row.business_view ?? null,
|
||||
payload: row.payload ?? null,
|
||||
} : null;
|
||||
return (
|
||||
<aside style={detailStyle} aria-label="业务视图详情">
|
||||
<div className="de-artifact-preview">
|
||||
<div>
|
||||
<strong>{rowTitle(row)}</strong>
|
||||
<button type="button" className="de-icon-btn" onClick={onClose} title="关闭" aria-label="关闭"><X size={15} /></button>
|
||||
</div>
|
||||
<pre>{[
|
||||
`实体:${rowEntityId(row)}`,
|
||||
`状态:${displayText(row.status)}`,
|
||||
`同步:${displayText(row.sync_status)}`,
|
||||
`Plan:${displayText(row.plan_id)}`,
|
||||
`Task:${displayText(row.task_id)}`,
|
||||
`Run:${displayText(row.run_id)}`,
|
||||
`时间:${rowTime(row)}`,
|
||||
row.sync_record_url ? `Sync Record:${row.sync_record_url}` : '',
|
||||
row.summary ? `摘要:${row.summary}` : '',
|
||||
].filter(Boolean).join('\n')}</pre>
|
||||
</div>
|
||||
{jsonPayload ? (
|
||||
<details className="de-business-json" open>
|
||||
<summary>JSON 详情</summary>
|
||||
<pre>{JSON.stringify(jsonPayload, null, 2)}</pre>
|
||||
</details>
|
||||
) : null}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BusinessView({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
|
||||
const [kind, setKind] = useState<DigitalEmployeeBusinessViewKind>(() => normalizeKind(initialKind));
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, entity: focusEntityId || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeBusinessViewRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<DigitalEmployeeBusinessViewRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setKind(normalizeKind(initialKind));
|
||||
}, [initialKind]);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusEntityId) setFilters((current) => ({ ...current, entity: focusEntityId }));
|
||||
}, [focusEntityId]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeBusinessView(kind, queryFromFilters(filters, pageNo));
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
setSelected((current) => {
|
||||
if (!current) return null;
|
||||
return (data.items ?? []).find((item) => rowEntityId(item) === rowEntityId(current)) ?? null;
|
||||
});
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '业务视图加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, kind, pageNo]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadRows();
|
||||
}, [loadRows]);
|
||||
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="业务视图">
|
||||
<span className="de-card-meta-text">{source || 'qimingclaw 本地业务查询'}</span>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div className="de-segmented-tabs" role="tablist" aria-label="业务视图类型">
|
||||
{VIEW_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={item.id === kind ? 'active' : ''}
|
||||
onClick={() => { setKind(item.id); setPageNo(1); setSelected(null); }}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label>
|
||||
<span>关键词 / 实体 ID</span>
|
||||
<input value={filters.entity} onChange={(event) => updateFilter('entity', event.target.value)} placeholder="plan / task / artifact" />
|
||||
</label>
|
||||
<label>
|
||||
<span>状态</span>
|
||||
<input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="pending" />
|
||||
</label>
|
||||
<label>
|
||||
<span>同步状态</span>
|
||||
<input value={filters.syncStatus} onChange={(event) => updateFilter('syncStatus', event.target.value)} placeholder="synced" />
|
||||
</label>
|
||||
<label>
|
||||
<span>起始时间</span>
|
||||
<input type="date" value={filters.updatedFrom} onChange={(event) => updateFilter('updatedFrom', event.target.value)} />
|
||||
</label>
|
||||
<label>
|
||||
<span>结束时间</span>
|
||||
<input type="date" value={filters.updatedTo} onChange={(event) => updateFilter('updatedTo', event.target.value)} />
|
||||
</label>
|
||||
<div className="de-business-actions">
|
||||
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="搜索" aria-label="搜索"><Search size={15} /></button>
|
||||
<button type="button" className="de-icon-btn" onClick={() => { setFilters(DEFAULT_FILTERS); setPageNo(1); }} title="重置" aria-label="重置"><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
|
||||
<div style={contentStyle} className="de-business-layout">
|
||||
<section style={tableWrapStyle} aria-label="业务视图列表">
|
||||
<table style={tableStyle} className="de-business-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>标题 / 实体</th>
|
||||
<th>状态</th>
|
||||
<th>关联</th>
|
||||
<th>扩展字段</th>
|
||||
<th>同步</th>
|
||||
<th>更新时间</th>
|
||||
<th>详情</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={7}>{loading ? '加载中...' : '暂无业务对象'}</td></tr>
|
||||
) : rows.map((row) => (
|
||||
<tr key={`${kind}:${rowEntityId(row)}:${rowTime(row)}`} className={selected && rowEntityId(selected) === rowEntityId(row) ? 'active' : ''}>
|
||||
<td><strong>{rowTitle(row)}</strong><code>{rowEntityId(row)}</code></td>
|
||||
<td><span className={statusClass(row.status)}>{displayText(row.status)}</span></td>
|
||||
<td><span>{displayText(row.plan_id)}</span><span>{displayText(row.task_id)}</span><span>{displayText(row.run_id)}</span></td>
|
||||
<td>{kindExtra(row, kind)}</td>
|
||||
<td>{displayText(row.sync_status)}</td>
|
||||
<td>{rowTime(row)}</td>
|
||||
<td><button type="button" className="de-icon-btn" onClick={() => setSelected(row)} title="详情" aria-label="详情"><Eye size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<DetailPanel row={selected} onClose={() => setSelected(null)} />
|
||||
</div>
|
||||
|
||||
<div className="de-business-pagination">
|
||||
<span>共 {total} 条</span>
|
||||
<label>
|
||||
<span>每页</span>
|
||||
<select value={filters.pageSize} onChange={(event) => updateFilter('pageSize', Number(event.target.value) || 20)}>
|
||||
<option value={10}>10</option>
|
||||
<option value={20}>20</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button>
|
||||
<span>{pageNo} / {pageCount}</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
pullRiskApprovalPolicy,
|
||||
pullRouteDecisionPolicy,
|
||||
recordApprovalAction,
|
||||
resolveApprovalConflict,
|
||||
decideRouteIntervention,
|
||||
restartManagedService,
|
||||
} from '@/lib/api';
|
||||
@@ -1736,6 +1737,42 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
}
|
||||
}, [approvalActionDrafts, selectedDate]);
|
||||
|
||||
const resolveApprovalConflictAction = useCallback(async (
|
||||
decision: DigitalEmployeeDecision,
|
||||
resolution: 'keep_local' | 'accept_remote' | 'mark_reviewed',
|
||||
) => {
|
||||
const actionKey = `approval-conflict:${decision.id}:${resolution}`;
|
||||
const draft = approvalActionDrafts[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT;
|
||||
setActionBusy(actionKey);
|
||||
setActionError(null);
|
||||
setActionNotice(null);
|
||||
try {
|
||||
const result = await resolveApprovalConflict(decision.id, {
|
||||
resolution,
|
||||
actor: 'digital_employee_operator',
|
||||
comment: compactText(draft.comment) || null,
|
||||
});
|
||||
if (!mountedRef.current) return;
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.error || result?.reason_codes?.join(', ') || '冲突裁决失败');
|
||||
}
|
||||
const latestProjection = await getDigitalEmployeeWorkday(selectedDate);
|
||||
if (!mountedRef.current) return;
|
||||
setWorkdayProjection(latestProjection);
|
||||
setApprovalActionDrafts((current) => ({
|
||||
...current,
|
||||
[decision.id]: { ...(current[decision.id] ?? EMPTY_APPROVAL_ACTION_DRAFT), comment: '' },
|
||||
}));
|
||||
setActionNotice('冲突裁决已记录。');
|
||||
} catch (error) {
|
||||
if (mountedRef.current) {
|
||||
setActionError(error instanceof Error ? error.message : '冲突裁决失败');
|
||||
}
|
||||
} finally {
|
||||
if (mountedRef.current) setActionBusy(null);
|
||||
}
|
||||
}, [approvalActionDrafts, selectedDate]);
|
||||
|
||||
const controlTaskAgent = useCallback(async (
|
||||
agent: DigitalEmployeeTaskAgentState,
|
||||
command: TaskAgentControlCommand,
|
||||
@@ -3434,6 +3471,28 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{approvalConflict && (
|
||||
<div className="de-workbench-actions de-approval-governance-actions">
|
||||
{([
|
||||
['keep_local', '保留本地'],
|
||||
['accept_remote', '采纳管理端'],
|
||||
['mark_reviewed', '标记已复核'],
|
||||
] as const).map(([resolution, label]) => {
|
||||
const busyKey = `approval-conflict:${decision.id}:${resolution}`;
|
||||
return (
|
||||
<button
|
||||
key={resolution}
|
||||
type="button"
|
||||
className={resolution === 'accept_remote' ? 'de-workbench-action-accent' : ''}
|
||||
disabled={actionBusy === busyKey}
|
||||
onClick={() => { void resolveApprovalConflictAction(decision, resolution); }}
|
||||
>
|
||||
<span>{actionBusy === busyKey ? '记录中...' : label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="de-approval-action-fields">
|
||||
<input
|
||||
value={draft.comment}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
|
||||
import { Download, Eye, FolderOpen, History } from 'lucide-react';
|
||||
import { CheckCircle, Download, Eye, FileText, FolderOpen, History, Send, ShieldCheck, Trash2 } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import {
|
||||
getDigitalEmployeeWorkday,
|
||||
postDigitalEmployeeWorkdayAction,
|
||||
accessArtifact,
|
||||
cleanupArtifact,
|
||||
getArtifactDetail,
|
||||
getArtifactVersions,
|
||||
recordDailyReportDelivery,
|
||||
setArtifactRetention,
|
||||
} from '@/lib/api';
|
||||
import type {
|
||||
@@ -31,7 +33,7 @@ const panelStyle: CSSProperties = {
|
||||
background: 'linear-gradient(180deg, rgba(244,250,255,0.82), rgba(235,246,255,0.58))',
|
||||
};
|
||||
|
||||
type BusyAction = 'update_report_schedule' | 'generate_daily_report' | null;
|
||||
type BusyAction = 'update_report_schedule' | 'generate_daily_report' | 'download_html' | 'download_pdf' | 'send_report' | 'confirm_report' | 'request_approval' | null;
|
||||
|
||||
function todayInputDate(): string {
|
||||
const date = new Date();
|
||||
@@ -90,6 +92,25 @@ function downloadTextFile(filename: string, content: string): void {
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function downloadBlobFile(filename: string, content: BlobPart, mime: string): void {
|
||||
const blob = new Blob([content], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function base64ToBytes(base64: string): Uint8Array {
|
||||
const binary = window.atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function reportFileName(date: string): string {
|
||||
return `qimingclaw-digital-report-${date}.txt`;
|
||||
}
|
||||
@@ -194,6 +215,18 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
return <p className="de-empty-text" style={{ padding: '18px 0 6px' }}>运行产物出现后,这里会展示文件、输出和来源记录。</p>;
|
||||
}
|
||||
|
||||
const mergeArtifactDetail = (artifact: DigitalEmployeeReportArtifactSource, item: any): DigitalEmployeeReportArtifactSource => ({
|
||||
...artifact,
|
||||
retention_status: String(item?.retention_status || artifact.retention_status || 'unmanaged'),
|
||||
retention_until: String(item?.retention_until || artifact.retention_until || '') || null,
|
||||
last_retention_action: String(item?.last_retention_action || artifact.last_retention_action || '') || null,
|
||||
cleanup_status: String(item?.cleanup_status || artifact.cleanup_status || '') || null,
|
||||
deleted_at: String(item?.deleted_at || artifact.deleted_at || '') || null,
|
||||
cleanup_reason: String(item?.cleanup_reason || artifact.cleanup_reason || '') || null,
|
||||
cleanup_event_id: String(item?.cleanup_event_id || artifact.cleanup_event_id || '') || null,
|
||||
access: item?.access || artifact.access,
|
||||
});
|
||||
|
||||
const runAccess = async (artifact: DigitalEmployeeReportArtifactSource, action: 'preview' | 'download' | 'open_location') => {
|
||||
if (!artifact.id || busyKey) return;
|
||||
const key = `${artifact.id}:${action}`;
|
||||
@@ -230,7 +263,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
getArtifactDetail(artifact.id),
|
||||
getArtifactVersions(artifact.id),
|
||||
]);
|
||||
setDetail({ artifact, item, versions });
|
||||
setDetail({ artifact: mergeArtifactDetail(artifact, item), item, versions });
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : '读取产物详情失败');
|
||||
} finally {
|
||||
@@ -253,7 +286,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
getArtifactDetail(artifact.id),
|
||||
getArtifactVersions(artifact.id),
|
||||
]);
|
||||
setDetail({ artifact: { ...artifact, retention_status: String(item?.retention_status || artifact.retention_status || 'unmanaged') }, item, versions });
|
||||
setDetail({ artifact: mergeArtifactDetail(artifact, item), item, versions });
|
||||
setNotice('保留策略已记录');
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : '保留策略写入失败');
|
||||
@@ -262,6 +295,34 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
}
|
||||
};
|
||||
|
||||
const runCleanup = async () => {
|
||||
const artifact = detail?.artifact;
|
||||
if (!artifact?.id || busyKey) return;
|
||||
setBusyKey(`${artifact.id}:cleanup`);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await cleanupArtifact(artifact.id, {
|
||||
reason: 'digital_employee_artifact_cleanup',
|
||||
retention_status: artifact.retention_status || null,
|
||||
last_retention_action: artifact.last_retention_action || null,
|
||||
});
|
||||
if (!result?.ok) {
|
||||
setNotice(result?.error || '清理文件失败');
|
||||
return;
|
||||
}
|
||||
const [item, versions] = await Promise.all([
|
||||
getArtifactDetail(artifact.id),
|
||||
getArtifactVersions(artifact.id),
|
||||
]);
|
||||
setDetail({ artifact: mergeArtifactDetail(artifact, item), item, versions });
|
||||
setNotice('清理已记录');
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : '清理文件失败');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="de-artifact-source-list" aria-label="日报产物来源">
|
||||
@@ -282,6 +343,7 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
<div className="de-artifact-source-meta">
|
||||
<span>{artifact.version || 'local-v1'}</span>
|
||||
<span>{artifact.retention_status || 'unmanaged'}</span>
|
||||
{artifact.cleanup_status ? <span>{artifact.cleanup_status}</span> : null}
|
||||
{artifact.delivery_status ? <span>{artifact.delivery_status}</span> : null}
|
||||
{artifact.access?.last_access_status ? <span>{artifact.access.last_access_status}</span> : null}
|
||||
</div>
|
||||
@@ -300,11 +362,24 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
{detail ? (
|
||||
<div className="de-artifact-preview">
|
||||
<div><strong>{detail.artifact.name}</strong><button type="button" className="de-btn-secondary" onClick={() => setDetail(null)}>关闭</button></div>
|
||||
<div className="de-artifact-source-meta">
|
||||
<span>{detail.artifact.retention_status || 'unmanaged'}</span>
|
||||
{detail.artifact.cleanup_status ? <span>{detail.artifact.cleanup_status}</span> : null}
|
||||
{detail.artifact.deleted_at ? <span>{detail.artifact.deleted_at}</span> : null}
|
||||
</div>
|
||||
<div className="de-artifact-retention-actions">
|
||||
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_keep')}>保留</button>
|
||||
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_expire')}>到期</button>
|
||||
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('mark_review')}>复核</button>
|
||||
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => runRetention('clear_retention')}>清除</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary"
|
||||
disabled={busyKey !== null || detail.artifact.cleanup_status === 'deleted' || !(detail.artifact.retention_status === 'expired' || detail.artifact.last_retention_action === 'mark_expire') || !(detail.artifact.access?.downloadable || detail.artifact.access?.openable)}
|
||||
onClick={() => { void runCleanup(); }}
|
||||
>
|
||||
<Trash2 size={14} /> 清理文件
|
||||
</button>
|
||||
</div>
|
||||
<div className="de-artifact-version-list">
|
||||
{(detail.versions.length > 0 ? detail.versions : [detail.item]).filter(Boolean).map((item) => (
|
||||
@@ -427,6 +502,70 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
||||
);
|
||||
}, [artifactSources, report, runDetails, taskSections, today]);
|
||||
|
||||
const downloadReportArtifact = useCallback(async (format: 'html' | 'pdf') => {
|
||||
if (!report || report.status !== 'ready') return;
|
||||
const artifactId = format === 'html' ? report.html_artifact_id : report.pdf_artifact_id;
|
||||
if (!artifactId) return;
|
||||
setBusyAction(format === 'html' ? 'download_html' : 'download_pdf');
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await accessArtifact(artifactId, 'download');
|
||||
if (!result?.ok) {
|
||||
setMessage(result?.error || '日报导出失败');
|
||||
return;
|
||||
}
|
||||
const preview = result.preview || {};
|
||||
if (format === 'html') {
|
||||
downloadBlobFile(report.html_filename || reportFileName(today).replace(/\.txt$/, '.html'), String(preview.text || report.html_content || ''), 'text/html;charset=utf-8');
|
||||
setMessage('HTML 日报已导出。');
|
||||
} else {
|
||||
const base64 = String(preview.base64 || '');
|
||||
if (!base64) {
|
||||
setMessage('PDF 日报内容不可用');
|
||||
return;
|
||||
}
|
||||
downloadBlobFile(report.pdf_filename || reportFileName(today).replace(/\.txt$/, '.pdf'), base64ToBytes(base64), 'application/pdf');
|
||||
setMessage('PDF 日报已导出。');
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setMessage(error instanceof Error ? error.message : '日报导出失败');
|
||||
} finally {
|
||||
if (mountedRef.current) setBusyAction(null);
|
||||
}
|
||||
}, [report, today]);
|
||||
|
||||
const recordDelivery = useCallback(async (action: 'send' | 'confirm' | 'request_approval') => {
|
||||
if (!report?.report_id) return;
|
||||
const busyMap = {
|
||||
send: 'send_report',
|
||||
confirm: 'confirm_report',
|
||||
request_approval: 'request_approval',
|
||||
} as const;
|
||||
setBusyAction(busyMap[action]);
|
||||
setMessage(null);
|
||||
try {
|
||||
const result = await recordDailyReportDelivery({
|
||||
reportId: report.report_id,
|
||||
action,
|
||||
status: action === 'send' ? 'sent' : action === 'confirm' ? 'confirmed' : 'pending',
|
||||
actor: action === 'confirm' ? 'management' : 'digital_employee_operator',
|
||||
endpoint: 'management-console',
|
||||
});
|
||||
if (!result?.eventId && !result?.event_id) {
|
||||
setMessage('日报交付记录未写入');
|
||||
return;
|
||||
}
|
||||
setMessage(action === 'send' ? '日报发送记录已写入。' : action === 'confirm' ? '管理端确认回执已写入。' : '日报发送审批已创建。');
|
||||
await fetchProjection();
|
||||
} catch (error) {
|
||||
if (!mountedRef.current) return;
|
||||
setMessage(error instanceof Error ? error.message : '日报交付记录失败');
|
||||
} finally {
|
||||
if (mountedRef.current) setBusyAction(null);
|
||||
}
|
||||
}, [fetchProjection, report?.report_id]);
|
||||
|
||||
return (
|
||||
<div style={{ padding: '14px 18px' }}>
|
||||
<GlassCard>
|
||||
@@ -482,6 +621,56 @@ export default function DailyReport({ focusSection }: { focusSection?: string |
|
||||
<Download size={14} aria-hidden="true" />
|
||||
下载日报
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!report?.html_artifact_id || busyAction === 'download_html'}
|
||||
onClick={() => { void downloadReportArtifact('html'); }}
|
||||
title="导出 HTML 日报"
|
||||
>
|
||||
<FileText size={14} aria-hidden="true" />
|
||||
导出 HTML
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!report?.pdf_artifact_id || busyAction === 'download_pdf'}
|
||||
onClick={() => { void downloadReportArtifact('pdf'); }}
|
||||
title="导出 PDF 日报"
|
||||
>
|
||||
<Download size={14} aria-hidden="true" />
|
||||
导出 PDF
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!report?.report_id || busyAction === 'send_report'}
|
||||
onClick={() => { void recordDelivery('send'); }}
|
||||
title="记录日报发送"
|
||||
>
|
||||
<Send size={14} aria-hidden="true" />
|
||||
记录发送
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!report?.report_id || busyAction === 'confirm_report'}
|
||||
onClick={() => { void recordDelivery('confirm'); }}
|
||||
title="写入管理端确认"
|
||||
>
|
||||
<CheckCircle size={14} aria-hidden="true" />
|
||||
确认回执
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
disabled={!report?.report_id || busyAction === 'request_approval'}
|
||||
onClick={() => { void recordDelivery('request_approval'); }}
|
||||
title="发起日报审批"
|
||||
>
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
发起审批
|
||||
</button>
|
||||
</div>
|
||||
<div className="de-report-metrics">
|
||||
<div className="de-report-metric"><span>任务总数</span><strong>{totals.task_count}</strong></div>
|
||||
|
||||
@@ -23,6 +23,7 @@ import CommandDeck from './CommandDeck';
|
||||
import MissionCenter from './MissionCenter';
|
||||
import SkillLibrary from './SkillLibrary';
|
||||
import DailyReport from './DailyReport';
|
||||
import BusinessView from './BusinessView';
|
||||
import OpsSettings from './OpsSettings';
|
||||
import type { DigitalNavigationTarget, DigitalTab } from './navigation';
|
||||
|
||||
@@ -31,6 +32,7 @@ const TABS: { id: DigitalTab; label: string }[] = [
|
||||
{ id: 'missions', label: '任务中心' },
|
||||
{ id: 'skills', label: '技能库' },
|
||||
{ id: 'reports', label: '日报' },
|
||||
{ id: 'business', label: '业务视图' },
|
||||
{ id: 'settings', label: '设置' },
|
||||
];
|
||||
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'qimingclaw:digital-close';
|
||||
@@ -87,6 +89,8 @@ function targetFromLocation(pathname: string, search: string): DigitalNavigation
|
||||
taskId: params.get('task'),
|
||||
skillId: params.get('skill'),
|
||||
reportSection: params.get('section'),
|
||||
businessKind: params.get('kind'),
|
||||
entityId: params.get('entity'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -103,6 +107,7 @@ function TabContent({
|
||||
case 'missions': return <MissionCenter focusMissionId={target.missionId} focusTaskId={target.taskId} focusDate={target.date} />;
|
||||
case 'skills': return <SkillLibrary focusSkillId={target.skillId} />;
|
||||
case 'reports': return <DailyReport focusSection={target.reportSection} />;
|
||||
case 'business': return <BusinessView initialKind={target.businessKind} focusEntityId={target.entityId} />;
|
||||
case 'settings': return <OpsSettings />;
|
||||
}
|
||||
}
|
||||
@@ -284,6 +289,8 @@ export default function DigitalEmployeeShell() {
|
||||
if (nextTarget.taskId) params.set('task', nextTarget.taskId);
|
||||
if (nextTarget.skillId) params.set('skill', nextTarget.skillId);
|
||||
if (nextTarget.reportSection) params.set('section', nextTarget.reportSection);
|
||||
if (nextTarget.businessKind) params.set('kind', nextTarget.businessKind);
|
||||
if (nextTarget.entityId) params.set('entity', nextTarget.entityId);
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
search: params.toString() ? `?${params.toString()}` : '',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'settings';
|
||||
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'business' | 'settings';
|
||||
|
||||
export type DigitalNavigationTarget = {
|
||||
tab: DigitalTab;
|
||||
@@ -7,6 +7,8 @@ export type DigitalNavigationTarget = {
|
||||
taskId?: string | null;
|
||||
skillId?: string | null;
|
||||
reportSection?: string | null;
|
||||
businessKind?: string | null;
|
||||
entityId?: string | null;
|
||||
};
|
||||
|
||||
export function domSafeId(value: string): string {
|
||||
|
||||
@@ -332,6 +332,53 @@ export interface BusinessViewResponse<T> {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeBusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals';
|
||||
|
||||
export interface DigitalEmployeeBusinessViewQuery {
|
||||
entity_id?: string | null;
|
||||
status?: string | null;
|
||||
sync_status?: string | null;
|
||||
updated_from?: string | null;
|
||||
updated_to?: string | null;
|
||||
page_no?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeBusinessViewRow extends Record<string, unknown> {
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | null;
|
||||
plan_id?: string | null;
|
||||
task_id?: string | null;
|
||||
run_id?: string | null;
|
||||
event_id?: string | null;
|
||||
artifact_id?: string | null;
|
||||
approval_id?: string | null;
|
||||
title?: string | null;
|
||||
status?: string | null;
|
||||
sync_status?: string | null;
|
||||
summary?: string | null;
|
||||
kind?: string | null;
|
||||
operation?: string | null;
|
||||
uri?: string | null;
|
||||
decision?: string | null;
|
||||
approval_conflict_resolution?: string | null;
|
||||
approval_conflict_resolved_at?: string | null;
|
||||
approval_conflict_resolution_actor?: string | null;
|
||||
approval_conflict_resolution_event_id?: string | null;
|
||||
task_count?: number | null;
|
||||
run_count?: number | null;
|
||||
latest_event_at?: string | null;
|
||||
last_event_message?: string | null;
|
||||
occurred_at?: string | null;
|
||||
started_at?: string | null;
|
||||
finished_at?: string | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
sync_record_url?: string | null;
|
||||
business_view?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export type ChatRouteMode = 'auto' | 'chat' | 'direct' | 'plan';
|
||||
|
||||
export interface WsMessage {
|
||||
@@ -731,6 +778,10 @@ export interface ArtifactEntry {
|
||||
access_policy?: string | null;
|
||||
last_access_status?: string | null;
|
||||
};
|
||||
cleanup_status?: string | null;
|
||||
deleted_at?: string | null;
|
||||
cleanup_reason?: string | null;
|
||||
cleanup_event_id?: string | null;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
@@ -1032,6 +1083,10 @@ export interface DigitalEmployeeReportArtifactSource {
|
||||
retention_status?: string | null;
|
||||
retention_until?: string | null;
|
||||
last_retention_action?: string | null;
|
||||
cleanup_status?: string | null;
|
||||
deleted_at?: string | null;
|
||||
cleanup_reason?: string | null;
|
||||
cleanup_event_id?: string | null;
|
||||
file_size?: number | null;
|
||||
source_tool?: string | null;
|
||||
source_label?: string | null;
|
||||
@@ -1055,6 +1110,8 @@ export interface DigitalEmployeeDelivery {
|
||||
export interface DigitalEmployeeDailyReport {
|
||||
report_id?: string | null;
|
||||
artifact_id?: string | null;
|
||||
html_artifact_id?: string | null;
|
||||
pdf_artifact_id?: string | null;
|
||||
event_id?: string | null;
|
||||
date: string;
|
||||
title: string;
|
||||
@@ -1076,7 +1133,10 @@ export interface DigitalEmployeeDailyReport {
|
||||
detail_lines: string[];
|
||||
artifact_sources?: DigitalEmployeeReportArtifactSource[];
|
||||
download_filename?: string | null;
|
||||
html_filename?: string | null;
|
||||
pdf_filename?: string | null;
|
||||
text_content?: string | null;
|
||||
html_content?: string | null;
|
||||
pdf_available: boolean;
|
||||
pdf_url?: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user