吸收数字员工运营预览台

This commit is contained in:
baiyanyun
2026-06-12 19:57:38 +08:00
parent 871bd715c4
commit 1a8e0fb51f
23 changed files with 3344 additions and 277 deletions

View File

@@ -17,6 +17,17 @@ import type {
DigitalEmployeeBusinessViewKind,
DigitalEmployeeBusinessViewQuery,
DigitalEmployeeBusinessViewRow,
DigitalEmployeeBatchActionResult,
DigitalEmployeeInterventionWorkbenchResponse,
DigitalEmployeeMemoryGovernanceResponse,
DigitalEmployeeSkillAuditWorkbenchResponse,
DigitalEmployeeWorkbenchBatchResult,
DigitalEmployeeOpsWorkbenchResponse,
DigitalEmployeeNotificationWorkbenchResponse,
DigitalEmployeeDailyReportWorkbenchResponse,
DigitalEmployeePolicyCenterResponse,
DigitalEmployeePolicyPullBatchResult,
DigitalEmployeeSchedulerWorkbenchResponse,
PlanStepBusinessRow,
PlanStepDispatchPolicy,
PlanStepDispatchPolicyPullResult,
@@ -295,7 +306,7 @@ export function deleteCronJob(id: string): Promise<void> {
}
export function patchCronJob(
id: string,
patch: { name?: string; schedule?: string; command?: string },
patch: { name?: string; schedule?: string; command?: string; enabled?: boolean },
): Promise<CronJob> {
return apiFetch<CronJob | { status: string; job: CronJob }>(
`/api/cron/${encodeURIComponent(id)}`,
@@ -336,6 +347,40 @@ export function patchCronSettings(
});
}
export async function getDigitalEmployeeSchedulerWorkbench(): Promise<DigitalEmployeeSchedulerWorkbenchResponse> {
const [jobs, settings] = await Promise.all([
getSchedulerJobs(),
getCronSettings().catch(() => ({ enabled: false, catch_up_on_startup: false, max_run_history: 20 })),
]);
const runEntries = await Promise.all(jobs.slice(0, 30).map(async (job) => {
const runs = await getCronRuns(job.id, 5).catch(() => []);
return [job.id, runs] as const;
}));
const runsByJob = new Map(runEntries);
return {
items: jobs.map((job) => {
const runs = runsByJob.get(job.id) ?? [];
const latest = runs[0];
const schedule = job.schedule && typeof job.schedule === 'object' ? job.schedule as Record<string, unknown> : {};
return {
schedule_id: job.id,
name: job.name || job.id,
enabled: job.enabled !== false,
status: job.enabled === false ? 'paused' : String(job.last_status || schedule.status || 'enabled'),
cron_expr: job.expression || String(schedule.expression || ''),
next_run: job.next_run || null,
last_run: job.last_run || latest?.finished_at || latest?.started_at || null,
last_status: job.last_status || latest?.status || null,
latest_run_error: job.last_output || latest?.output || null,
run_count: runs.length,
payload: { job, runs },
};
}),
settings,
source: 'qimingclaw-adapter',
};
}
// ---------------------------------------------------------------------------
// Integrations
// ---------------------------------------------------------------------------
@@ -521,6 +566,117 @@ export function decideRouteIntervention(
});
}
export function decideRouteInterventionsBatch(
routeDecisionIds: string[],
input: { decision: 'mark_reviewed' | 'dismiss' | 'retry_policy_check'; actor?: string | null; comment?: string | null },
): Promise<DigitalEmployeeBatchActionResult> {
return apiFetch<DigitalEmployeeBatchActionResult>('/api/route-decisions/interventions/batch', {
method: 'POST',
body: JSON.stringify({ route_decision_ids: routeDecisionIds, ...input }),
});
}
export function getDigitalEmployeeInterventionWorkbench(
params: Record<string, string | number | null | undefined> = {},
): Promise<DigitalEmployeeInterventionWorkbenchResponse> {
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<DigitalEmployeeInterventionWorkbenchResponse>(`/api/digital-employee/interventions/workbench${suffix ? `?${suffix}` : ''}`);
}
export function getDigitalEmployeeMemoryGovernance(
params: Record<string, string | number | null | undefined> = {},
): Promise<DigitalEmployeeMemoryGovernanceResponse> {
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<DigitalEmployeeMemoryGovernanceResponse>(`/api/digital-employee/memory-governance${suffix ? `?${suffix}` : ''}`);
}
export function setMemoryGovernanceStatus(
memoryIds: string[],
action: 'archive' | 'restore' | 'mark_reviewed',
options: { actor?: string | null; reason?: string | null } = {},
): Promise<DigitalEmployeeBatchActionResult> {
return apiFetch<DigitalEmployeeBatchActionResult>('/api/digital-employee/memory-governance/status/batch', {
method: 'POST',
body: JSON.stringify({ memory_ids: memoryIds, action, ...options }),
});
}
export function getDigitalEmployeeSkillAuditWorkbench(
params: Record<string, string | number | null | undefined> = {},
): Promise<DigitalEmployeeSkillAuditWorkbenchResponse> {
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<DigitalEmployeeSkillAuditWorkbenchResponse>(`/api/digital-employee/skill-audit-workbench${suffix ? `?${suffix}` : ''}`);
}
export function getDigitalEmployeeOpsWorkbench(
params: Record<string, string | number | null | undefined> = {},
): Promise<DigitalEmployeeOpsWorkbenchResponse> {
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<DigitalEmployeeOpsWorkbenchResponse>(`/api/digital-employee/ops-workbench${suffix ? `?${suffix}` : ''}`);
}
export function runDigitalEmployeeOpsBatchAction(
opsIds: string[],
action: 'mark_reviewed' | 'dismiss' | 'open_related_view' | 'retry_policy_pull' | 'safe_restart_service',
options: { actor?: string | null; reason?: string | null } = {},
): Promise<DigitalEmployeeWorkbenchBatchResult> {
return apiFetch<DigitalEmployeeWorkbenchBatchResult>('/api/digital-employee/ops-workbench/actions/batch', {
method: 'POST',
body: JSON.stringify({ ops_ids: opsIds, action, ...options }),
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
});
}
export function getDigitalEmployeeNotificationWorkbench(
params: Record<string, string | number | null | undefined> = {},
): Promise<DigitalEmployeeNotificationWorkbenchResponse> {
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<DigitalEmployeeNotificationWorkbenchResponse>(`/api/digital-employee/notifications/workbench${suffix ? `?${suffix}` : ''}`);
}
export function runDigitalEmployeeNotificationBatchAction(
notificationIds: string[],
action: 'mark_read' | 'dismiss' | 'pull_updates' | 'open_desktop_settings',
options: { actor?: string | null; reason?: string | null } = {},
): Promise<DigitalEmployeeWorkbenchBatchResult> {
return apiFetch<DigitalEmployeeWorkbenchBatchResult>('/api/digital-employee/notifications/actions/batch', {
method: 'POST',
body: JSON.stringify({ notification_ids: notificationIds, action, ...options }),
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
});
}
export function getDigitalEmployeeDailyReportWorkbench(
params: Record<string, string | number | null | undefined> = {},
): Promise<DigitalEmployeeDailyReportWorkbenchResponse> {
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<DigitalEmployeeDailyReportWorkbenchResponse>(`/api/digital-employee/daily-reports/workbench${suffix ? `?${suffix}` : ''}`);
}
export function routeIngress(
body: IngressRouteRequest,
): Promise<IngressRouteResponse> {
@@ -698,6 +854,77 @@ export function pullRouteDecisionPolicy(): Promise<RouteDecisionPolicyPullResult
});
}
export async function getDigitalEmployeePolicyCenter(): Promise<DigitalEmployeePolicyCenterResponse> {
const [dispatchPolicy, riskPolicy] = await Promise.all([
getPlanStepDispatchPolicy(),
getRiskApprovalPolicy(),
]);
return {
plan_step_dispatch_policy: dispatchPolicy,
risk_approval_policy: riskPolicy,
source: 'qimingclaw-adapter',
items: [
{
policy_id: 'plan_step_dispatch',
title: 'PlanStep 派发策略',
status: dispatchPolicy.enabled ? 'enabled' : 'disabled',
source: dispatchPolicy.source,
updated_at: dispatchPolicy.updated_at,
last_pulled_at: dispatchPolicy.last_pulled_at,
last_pull_error: dispatchPolicy.last_pull_error,
summary: `max_per_sweep=${dispatchPolicy.max_per_sweep}; auto_dispatch_ready_steps=${dispatchPolicy.auto_dispatch_ready_steps}`,
payload: dispatchPolicy,
},
{
policy_id: 'risk_approval',
title: '风险审批策略',
status: riskPolicy.enabled ? 'enabled' : 'disabled',
source: riskPolicy.source,
updated_at: riskPolicy.updated_at,
last_pulled_at: riskPolicy.last_pulled_at,
last_pull_error: riskPolicy.last_pull_error,
summary: `approval_required=${riskPolicy.approval_required_risk_levels.join(',')}; auto_reject_actions=${riskPolicy.auto_reject_actions.length}`,
payload: riskPolicy,
},
{
policy_id: 'route_decision',
title: '入口路由策略',
status: 'pull_available',
source: 'remote_pull',
summary: '通过 /api/route-decision/policy/pull 获取管理端路由策略快照。',
},
{
policy_id: 'skill',
title: '技能策略',
status: 'pull_available',
source: 'remote_pull',
summary: '通过 /api/skill/policies/pull 同步远端技能启停策略。',
},
],
};
}
export async function pullDigitalEmployeePoliciesBatch(): Promise<DigitalEmployeePolicyPullBatchResult> {
const jobs: Array<{ policy_id: string; run: () => Promise<unknown> }> = [
{ policy_id: 'plan_step_dispatch', run: pullPlanStepDispatchPolicy },
{ policy_id: 'risk_approval', run: pullRiskApprovalPolicy },
{ policy_id: 'route_decision', run: pullRouteDecisionPolicy },
{ policy_id: 'skill', run: pullSkillPolicies },
];
const items = [] as DigitalEmployeePolicyPullBatchResult['items'];
for (const job of jobs) {
try {
const result = await job.run();
const record = result as { ok?: boolean; error?: string | null };
items.push({ policy_id: job.policy_id, ok: record.ok !== false, error: record.error ?? null, result });
} catch (error) {
items.push({ policy_id: job.policy_id, ok: false, error: error instanceof Error ? error.message : String(error) });
}
}
const pulled = items.filter((item) => item.ok).length;
return { ok: pulled > 0, total: items.length, pulled, failed: items.length - pulled, items };
}
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
@@ -726,6 +953,7 @@ export function getDigitalEmployeeBusinessView(
runs: '/api/digital-employee/runs',
events: '/api/digital-employee/events',
artifacts: '/api/digital-employee/artifacts',
'artifact-groups': '/api/digital-employee/artifact-groups',
approvals: '/api/digital-employee/approvals',
};
const query = new URLSearchParams();
@@ -961,6 +1189,17 @@ export function setArtifactRetention(
});
}
export function setArtifactRetentionBatch(
artifactIds: string[],
action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention',
options: Record<string, string | null | undefined> = {},
): Promise<any> {
return apiFetch('/api/digital-employee/artifacts/retention/batch', {
method: 'POST',
body: JSON.stringify({ artifact_ids: artifactIds, action, ...options }),
});
}
export function cleanupArtifact(
artifactId: string,
options: Record<string, string | null | undefined> = {},
@@ -992,6 +1231,17 @@ export function resolveApprovalConflict(
});
}
export function resolveApprovalConflictsBatch(
approvalIds: string[],
resolution: 'keep_local' | 'mark_reviewed',
options: { actor?: string | null; comment?: string | null } = {},
): Promise<DigitalEmployeeBatchActionResult> {
return apiFetch<DigitalEmployeeBatchActionResult>('/api/approval/conflicts/resolve/batch', {
method: 'POST',
body: JSON.stringify({ approval_ids: approvalIds, resolution, ...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',

View File

@@ -1,8 +1,8 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { Eye, RefreshCw, Search, X } from 'lucide-react';
import { CheckSquare, 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 { getDigitalEmployeeBusinessView, setArtifactRetentionBatch } from '@/lib/api';
import type {
DigitalEmployeeBusinessViewKind,
DigitalEmployeeBusinessViewQuery,
@@ -15,6 +15,7 @@ const VIEW_OPTIONS: Array<{ id: DigitalEmployeeBusinessViewKind; label: string }
{ id: 'runs', label: '运行' },
{ id: 'events', label: '事件' },
{ id: 'artifacts', label: '产物' },
{ id: 'artifact-groups', label: '产物聚合' },
{ id: 'approvals', label: '审批' },
];
@@ -106,10 +107,16 @@ function kindExtra(row: DigitalEmployeeBusinessViewRow, kind: DigitalEmployeeBus
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 === 'artifact-groups') return `产物 ${displayText(row.artifact_count, '0')} / 设备 ${displayText(row.device_count, '0')} / 版本 ${displayText(row.version_count, '0')}`;
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 rowArtifactIds(row: DigitalEmployeeBusinessViewRow): string[] {
if (Array.isArray(row.artifact_ids)) return row.artifact_ids.map(String).filter(Boolean);
return row.artifact_id ? [String(row.artifact_id)] : [];
}
function queryFromFilters(filters: FilterState, pageNo: number): DigitalEmployeeBusinessViewQuery {
return {
entity_id: filters.entity.trim() || null,
@@ -129,6 +136,13 @@ function statusClass(value: unknown): string {
return 'de-badge de-badge-info';
}
function relatedWorkbenchTarget(row: DigitalEmployeeBusinessViewRow): { label: string; search: string } | null {
if (row.notification_id || row.entity_type === 'notification') return { label: '打开通知运营', search: `?tab=notification-workbench&entity=${encodeURIComponent(rowEntityId(row))}` };
if (row.report_id || row.entity_type === 'daily_report') return { label: '打开日报运营', search: `?tab=daily-report-workbench&entity=${encodeURIComponent(rowEntityId(row))}` };
if (row.service_id || row.audit_id || row.entity_type === 'managed_service' || row.entity_type === 'audit') return { label: '打开处置台', search: `?tab=ops-workbench&entity=${encodeURIComponent(rowEntityId(row))}` };
return null;
}
function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | null; onClose: () => void }) {
if (!row) {
return <aside style={detailStyle}><div style={emptyDetailStyle}></div></aside>;
@@ -137,6 +151,7 @@ function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | n
business_view: row.business_view ?? null,
payload: row.payload ?? null,
} : null;
const relatedTarget = relatedWorkbenchTarget(row);
return (
<aside style={detailStyle} aria-label="业务视图详情">
<div className="de-artifact-preview">
@@ -155,6 +170,7 @@ function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | n
row.sync_record_url ? `Sync Record${row.sync_record_url}` : '',
row.summary ? `摘要:${row.summary}` : '',
].filter(Boolean).join('\n')}</pre>
{relatedTarget ? <button type="button" className="de-btn-secondary de-btn-sm" onClick={() => { window.location.search = relatedTarget.search; }}>{relatedTarget.label}</button> : null}
</div>
{jsonPayload ? (
<details className="de-business-json" open>
@@ -174,8 +190,10 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [selected, setSelected] = useState<DigitalEmployeeBusinessViewRow | null>(null);
const [selectedArtifactIds, setSelectedArtifactIds] = useState<string[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
useEffect(() => {
setKind(normalizeKind(initialKind));
@@ -193,6 +211,7 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
setSelectedArtifactIds((current) => current.filter((artifactId) => (data.items ?? []).some((item) => rowArtifactIds(item).includes(artifactId))));
setSelected((current) => {
if (!current) return null;
return (data.items ?? []).find((item) => rowEntityId(item) === rowEntityId(current)) ?? null;
@@ -211,12 +230,44 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
}, [loadRows]);
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
const supportsArtifactBatch = kind === 'artifacts' || kind === 'artifact-groups';
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
setFilters((current) => ({ ...current, [key]: value }));
setPageNo(1);
};
const toggleArtifactSelection = (row: DigitalEmployeeBusinessViewRow) => {
const ids = rowArtifactIds(row);
setSelectedArtifactIds((current) => {
const selectedAll = ids.length > 0 && ids.every((id) => current.includes(id));
return selectedAll
? current.filter((id) => !ids.includes(id))
: Array.from(new Set([...current, ...ids]));
});
};
const runArtifactBatch = async (action: 'mark_keep' | 'mark_expire' | 'mark_review') => {
if (selectedArtifactIds.length === 0 || loading) return;
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await setArtifactRetentionBatch(selectedArtifactIds, action, { reason: 'digital_employee_artifact_batch_governance' });
if (!result?.ok) {
setNotice(result?.error || '批量保留策略写入失败');
return;
}
setNotice(`批量保留策略已记录:${result.recorded ?? 0}/${result.total ?? selectedArtifactIds.length}`);
setSelectedArtifactIds([]);
await loadRows();
} catch (batchError) {
setError(batchError instanceof Error ? batchError.message : '批量保留策略写入失败');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<GlassCard>
@@ -264,6 +315,17 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
</div>
</div>
{supportsArtifactBatch ? (
<div className="de-business-actions" aria-label="批量保留治理">
<span className="de-card-meta-text"> {selectedArtifactIds.length} </span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => { void runArtifactBatch('mark_keep'); }}><CheckSquare size={14} /> </button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => { void runArtifactBatch('mark_expire'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => { void runArtifactBatch('mark_review'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => setSelectedArtifactIds([])}></button>
</div>
) : null}
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<div style={contentStyle} className="de-business-layout">
@@ -271,6 +333,7 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
<table style={tableStyle} className="de-business-table">
<thead>
<tr>
{supportsArtifactBatch ? <th></th> : null}
<th> / </th>
<th></th>
<th></th>
@@ -282,9 +345,12 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={7}>{loading ? '加载中...' : '暂无业务对象'}</td></tr>
<tr><td colSpan={supportsArtifactBatch ? 8 : 7}>{loading ? '加载中...' : '暂无业务对象'}</td></tr>
) : rows.map((row) => (
<tr key={`${kind}:${rowEntityId(row)}:${rowTime(row)}`} className={selected && rowEntityId(selected) === rowEntityId(row) ? 'active' : ''}>
{supportsArtifactBatch ? (
<td><input type="checkbox" checked={rowArtifactIds(row).length > 0 && rowArtifactIds(row).every((id) => selectedArtifactIds.includes(id))} onChange={() => toggleArtifactSelection(row)} aria-label="选择产物" /></td>
) : null}
<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>

View File

@@ -2518,6 +2518,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
{routeSummary.latest_intervention_status ? ` · 干预${routeSummary.latest_intervention_status}` : ''}
{routeSummary.policy_last_pull_error ? ` · 拉取失败` : ''}
</small>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'interventions', interventionKind: 'route_intervention' })}></button>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'policy-center' })}></button>
</div>
)}
@@ -2529,6 +2531,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<span><em>Token</em><strong>{formatCompactNumber(diagnosticSummary.cost.total_tokens)}</strong></span>
<span><em></em><strong>{formatEstimatedCostUsd(diagnosticSummary.cost.estimated_cost_usd)}</strong></span>
<small>{latestDiagnosticIssue ? `${latestDiagnosticIssue.category} · ${latestDiagnosticIssue.message}` : '诊断、审计和成本投影正常'}</small>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'ops-workbench', opsKind: latestDiagnosticIssue?.source === 'audit' ? 'audit' : 'diagnostic' })}></button>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'scheduler-workbench' })}></button>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'notification-workbench', notificationKind: 'all' })}></button>
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'skill-audit' })}></button>
</div>
)}
</div>
@@ -2801,6 +2807,13 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<Download size={14} aria-hidden="true" />
</button>
<button
type="button"
className="de-btn-secondary de-btn-sm"
onClick={() => onNavigate({ tab: 'daily-report-workbench', entityId: dailyReport?.report_id })}
>
</button>
</div>
<div className="de-report-meta">
<p>{dailyReport?.summary ?? '点击生成日报后AI 会基于当天所有任务写出总分结构日报。'}</p>
@@ -3560,6 +3573,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
<div className="de-decision-chain-note">
<span className={`de-badge ${approvalConflict ? 'de-badge-danger' : 'de-badge-success'}`}>{approvalConflict ? '冲突保护' : '本地记录'}</span>
<span>{approvalConflict ? '直接同意/驳回已暂停,请先同步或在管理端工作台排查。' : isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
{approvalConflict ? <button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'interventions', interventionKind: 'approval_conflict', entityId: decision.id })}></button> : null}
</div>
<div className="de-workbench-actions">
{decision.actions.map((action) => (

View File

@@ -11,6 +11,7 @@ import {
getArtifactVersions,
recordDailyReportDelivery,
setArtifactRetention,
setArtifactRetentionBatch,
} from '@/lib/api';
import type {
DigitalEmployeeDailyReport,
@@ -323,8 +324,38 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
}
};
const runBatchExpire = async () => {
if (busyKey) return;
const artifactIds = artifacts.map((artifact) => artifact.id).filter(Boolean);
if (artifactIds.length === 0) return;
setBusyKey('artifact-batch:mark_expire');
setNotice(null);
try {
const result = await setArtifactRetentionBatch(artifactIds, 'mark_expire', { reason: 'digital_employee_daily_report_batch_retention' });
if (!result?.ok) {
setNotice(result?.error || '批量标记到期失败');
return;
}
if (detail?.artifact.id) {
const [item, versions] = await Promise.all([
getArtifactDetail(detail.artifact.id),
getArtifactVersions(detail.artifact.id),
]);
setDetail({ artifact: mergeArtifactDetail(detail.artifact, item), item, versions });
}
setNotice(`批量标记到期已记录:${result.recorded ?? 0}/${result.total ?? artifactIds.length}`);
} catch (error) {
setNotice(error instanceof Error ? error.message : '批量标记到期失败');
} finally {
setBusyKey(null);
}
};
return (
<>
<div className="de-artifact-retention-actions">
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => { void runBatchExpire(); }}></button>
</div>
<div className="de-artifact-source-list" aria-label="日报产物来源">
{artifacts.map((artifact) => {
const previewable = Boolean(artifact.access?.previewable);

View File

@@ -0,0 +1,142 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { CheckCircle, RefreshCw, Search, Send, ShieldCheck, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { getDigitalEmployeeDailyReportWorkbench, postDigitalEmployeeWorkdayAction, recordDailyReportDelivery } from '@/lib/api';
import type { DigitalEmployeeDailyReportWorkbenchRow } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(130px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 1020, borderCollapse: 'collapse' };
type FilterState = { date: string; status: string; entityId: string; pageSize: number };
const DEFAULT_FILTERS: FilterState = { date: '', status: '', entityId: '', pageSize: 20 };
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function badgeClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (/failed|error|pending/.test(text)) return 'de-badge de-badge-warning';
if (/confirmed|sent|requested|ready/.test(text)) return 'de-badge de-badge-info';
return 'de-badge de-badge-info';
}
export default function DailyReportWorkbench({ focusEntityId }: { focusEntityId?: string | null }) {
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, entityId: focusEntityId || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeDailyReportWorkbenchRow[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [detail, setDetail] = useState<DigitalEmployeeDailyReportWorkbenchRow | null>(null);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeDailyReportWorkbench({
date: filters.date || null,
status: filters.status || null,
entity_id: filters.entityId || null,
page_no: pageNo,
page_size: filters.pageSize,
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '日报运营加载失败');
} finally {
setLoading(false);
}
}, [filters, 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);
};
const generateReport = async (row?: DigitalEmployeeDailyReportWorkbenchRow | null) => {
setLoading(true);
setNotice(null);
setError(null);
try {
await postDigitalEmployeeWorkdayAction({ action: 'generate_daily_report', date: row?.date || filters.date || undefined, duty_ids: [] });
setNotice('日报已生成。');
await loadRows();
} catch (generateError) {
setError(generateError instanceof Error ? generateError.message : '生成日报失败');
} finally {
setLoading(false);
}
};
const recordDelivery = async (row: DigitalEmployeeDailyReportWorkbenchRow, action: 'send' | 'confirm' | 'request_approval') => {
setLoading(true);
setNotice(null);
setError(null);
try {
await recordDailyReportDelivery({
reportId: row.report_id,
action,
status: action === 'send' ? 'sent' : action === 'confirm' ? 'confirmed' : 'approval_requested',
actor: 'digital_employee_operator',
message: 'daily_report_workbench_action_recorded',
});
setNotice(action === 'send' ? '发送记录已写入。' : action === 'confirm' ? '确认回执已记录。' : '请求审批已记录。');
await loadRows();
} catch (deliveryError) {
setError(deliveryError instanceof Error ? deliveryError.message : '日报动作失败');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="日报运营"><span className="de-card-meta-text">{source || '日报交付运营视图'}</span></CardTitleBar>
<div className="de-card-body">
<div style={toolbarStyle} className="de-business-toolbar">
<label><span></span><input type="date" value={filters.date} onChange={(event) => updateFilter('date', event.target.value)} /></label>
<label><span></span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="ready" /></label>
<label><span></span><input value={filters.entityId} onChange={(event) => updateFilter('entityId', event.target.value)} placeholder="report id" /></label>
<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-icon-btn" onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
</div>
<div className="de-business-actions" aria-label="日报运营动作">
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void generateReport(); }}></button>
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<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={row.report_id}>
<td><strong>{row.title}</strong><code>{row.date} · {row.report_id}</code></td>
<td><span className={badgeClass(row.status)}>{displayText(row.status)}</span></td>
<td>{displayText(row.delivery_status)}</td>
<td>{displayText(row.confirmation_status)}</td>
<td>{displayText(row.approval_status)}</td>
<td>{displayText(row.artifact_count, '0')}</td>
<td className="de-business-actions"><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void recordDelivery(row, 'send'); }} title="记录发送" aria-label="记录发送"><Send size={15} /></button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void recordDelivery(row, 'confirm'); }} title="确认回执" aria-label="确认回执"><CheckCircle size={15} /></button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void recordDelivery(row, 'request_approval'); }} title="请求审批" aria-label="请求审批"><ShieldCheck size={15} /></button></td>
</tr>
))}
</tbody></table></section>
<div className="de-business-pagination"><span> {total} </span><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>
{detail ? <div className="de-artifact-preview" aria-label="日报运营详情"><div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
</div>
);
}

View File

@@ -24,6 +24,14 @@ import MissionCenter from './MissionCenter';
import SkillLibrary from './SkillLibrary';
import DailyReport from './DailyReport';
import BusinessView from './BusinessView';
import InterventionWorkbench from './InterventionWorkbench';
import MemoryGovernance from './MemoryGovernance';
import SkillAuditWorkbench from './SkillAuditWorkbench';
import OpsWorkbench from './OpsWorkbench';
import NotificationWorkbench from './NotificationWorkbench';
import DailyReportWorkbench from './DailyReportWorkbench';
import PolicyCenter from './PolicyCenter';
import SchedulerWorkbench from './SchedulerWorkbench';
import OpsSettings from './OpsSettings';
import type { DigitalNavigationTarget, DigitalTab } from './navigation';
@@ -33,6 +41,14 @@ const TABS: { id: DigitalTab; label: string }[] = [
{ id: 'skills', label: '技能库' },
{ id: 'reports', label: '日报' },
{ id: 'business', label: '业务视图' },
{ id: 'interventions', label: '介入台' },
{ id: 'memory-governance', label: '记忆治理' },
{ id: 'skill-audit', label: '技能审计' },
{ id: 'ops-workbench', label: '处置台' },
{ id: 'notification-workbench', label: '通知运营' },
{ id: 'daily-report-workbench', label: '日报运营' },
{ id: 'policy-center', label: '策略中心' },
{ id: 'scheduler-workbench', label: '调度运营' },
{ id: 'settings', label: '设置' },
];
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'qimingclaw:digital-close';
@@ -90,6 +106,11 @@ function targetFromLocation(pathname: string, search: string): DigitalNavigation
skillId: params.get('skill'),
reportSection: params.get('section'),
businessKind: params.get('kind'),
interventionKind: params.get('kind'),
memoryQuery: params.get('q'),
auditSkillId: params.get('skill_id'),
opsKind: params.get('kind'),
notificationKind: params.get('kind'),
entityId: params.get('entity'),
};
}
@@ -105,9 +126,17 @@ function TabContent({
switch (tab) {
case 'home': return <CommandDeck onNavigate={onNavigate} />;
case 'missions': return <MissionCenter focusMissionId={target.missionId} focusTaskId={target.taskId} focusDate={target.date} />;
case 'skills': return <SkillLibrary focusSkillId={target.skillId} />;
case 'skills': return <SkillLibrary focusSkillId={target.skillId} onNavigate={onNavigate} />;
case 'reports': return <DailyReport focusSection={target.reportSection} />;
case 'business': return <BusinessView initialKind={target.businessKind} focusEntityId={target.entityId} />;
case 'interventions': return <InterventionWorkbench initialKind={target.interventionKind} focusEntityId={target.entityId} />;
case 'memory-governance': return <MemoryGovernance initialQuery={target.memoryQuery} focusEntityId={target.entityId} />;
case 'skill-audit': return <SkillAuditWorkbench focusSkillId={target.auditSkillId || target.skillId} />;
case 'ops-workbench': return <OpsWorkbench initialKind={target.opsKind} focusEntityId={target.entityId} />;
case 'notification-workbench': return <NotificationWorkbench initialKind={target.notificationKind} focusEntityId={target.entityId} />;
case 'daily-report-workbench': return <DailyReportWorkbench focusEntityId={target.entityId} />;
case 'policy-center': return <PolicyCenter />;
case 'scheduler-workbench': return <SchedulerWorkbench />;
case 'settings': return <OpsSettings />;
}
}
@@ -290,6 +319,11 @@ export default function DigitalEmployeeShell() {
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.interventionKind) params.set('kind', nextTarget.interventionKind);
if (nextTarget.memoryQuery) params.set('q', nextTarget.memoryQuery);
if (nextTarget.auditSkillId) params.set('skill_id', nextTarget.auditSkillId);
if (nextTarget.opsKind) params.set('kind', nextTarget.opsKind);
if (nextTarget.notificationKind) params.set('kind', nextTarget.notificationKind);
if (nextTarget.entityId) params.set('entity', nextTarget.entityId);
navigate({
pathname: location.pathname,

View File

@@ -0,0 +1,241 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { CheckSquare, RefreshCw, Search, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
decideRouteInterventionsBatch,
getDigitalEmployeeInterventionWorkbench,
resolveApprovalConflictsBatch,
} from '@/lib/api';
import type { DigitalEmployeeInterventionRow } from '@/types/api';
const VIEW_OPTIONS = [
{ id: 'all', label: '全部' },
{ id: 'approval_conflict', label: '审批冲突' },
{ id: 'route_intervention', label: '路由干预' },
{ id: 'risk_approval', label: '风险审批' },
{ id: 'action_notification', label: '待处理通知' },
] as const;
const pageStyle: CSSProperties = { padding: '14px 18px' };
const toolbarStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(180px, 1fr) repeat(3, minmax(120px, 160px)) auto',
gap: 8,
alignItems: 'end',
marginBottom: 12,
};
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
type FilterState = {
entity: string;
status: string;
priority: string;
pageSize: number;
};
const DEFAULT_FILTERS: FilterState = { entity: '', status: '', priority: '', pageSize: 20 };
function normalizeKind(value?: string | null): string {
return VIEW_OPTIONS.some((item) => item.id === value) ? value || 'all' : 'all';
}
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function rowTime(row: DigitalEmployeeInterventionRow): string {
return displayText(row.updated_at || row.occurred_at);
}
function statusClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (['failed', 'rejected', 'blocked', 'error'].some((key) => text.includes(key))) return 'de-badge de-badge-danger';
if (['pending', 'open', 'required', 'warn'].some((key) => text.includes(key))) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
function rowApprovalId(row: DigitalEmployeeInterventionRow): string | null {
return row.kind === 'approval_conflict' && row.approval_id ? String(row.approval_id) : null;
}
function rowRouteDecisionId(row: DigitalEmployeeInterventionRow): string | null {
return row.kind === 'route_intervention' && row.route_decision_id ? String(row.route_decision_id) : null;
}
export default function InterventionWorkbench({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
const [kind, setKind] = useState(() => normalizeKind(initialKind));
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, entity: focusEntityId || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeInterventionRow[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detail, setDetail] = useState<DigitalEmployeeInterventionRow | null>(null);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
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 getDigitalEmployeeInterventionWorkbench({
kind,
entity_id: filters.entity.trim() || null,
status: filters.status.trim() || null,
priority: filters.priority.trim() || null,
page_no: pageNo,
page_size: filters.pageSize,
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.intervention_id === id)));
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '介入台加载失败');
} finally {
setLoading(false);
}
}, [filters, kind, pageNo]);
useEffect(() => { void loadRows(); }, [loadRows]);
const selectedRows = useMemo(() => rows.filter((row) => selectedIds.includes(row.intervention_id)), [rows, selectedIds]);
const selectedApprovalIds = useMemo(() => selectedRows.map(rowApprovalId).filter((id): id is string => Boolean(id)), [selectedRows]);
const selectedRouteIds = useMemo(() => selectedRows.map(rowRouteDecisionId).filter((id): id is string => Boolean(id)), [selectedRows]);
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);
};
const toggleRow = (row: DigitalEmployeeInterventionRow) => {
setSelectedIds((current) => current.includes(row.intervention_id)
? current.filter((id) => id !== row.intervention_id)
: [...current, row.intervention_id]);
};
const runApprovalBatch = async (resolution: 'keep_local' | 'mark_reviewed') => {
if (selectedApprovalIds.length === 0 || loading) return;
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await resolveApprovalConflictsBatch(selectedApprovalIds, resolution, { actor: 'digital_employee_operator' });
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '批量审批冲突处理失败');
setNotice(`批量审批冲突已记录:${result.recorded}/${result.total}`);
setSelectedIds([]);
await loadRows();
} catch (batchError) {
setError(batchError instanceof Error ? batchError.message : '批量审批冲突处理失败');
} finally {
setLoading(false);
}
};
const runRouteBatch = async (decision: 'mark_reviewed' | 'dismiss') => {
if (selectedRouteIds.length === 0 || loading) return;
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await decideRouteInterventionsBatch(selectedRouteIds, { decision, actor: 'digital_employee_operator' });
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '批量路由干预处理失败');
setNotice(`批量路由干预已记录:${result.recorded}/${result.total}`);
setSelectedIds([]);
await loadRows();
} catch (batchError) {
setError(batchError instanceof Error ? batchError.message : '批量路由干预处理失败');
} finally {
setLoading(false);
}
};
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); setSelectedIds([]); }}>
{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="approval / route / task" /></label>
<label><span></span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="open" /></label>
<label><span></span><input value={filters.priority} onChange={(event) => updateFilter('priority', event.target.value)} placeholder="high" /></label>
<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>
<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>
<div className="de-business-actions" aria-label="批量介入动作">
<span className="de-card-meta-text"> {selectedIds.length} </span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedApprovalIds.length === 0 || loading} onClick={() => { void runApprovalBatch('keep_local'); }}><CheckSquare size={14} /> </button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedApprovalIds.length === 0 || loading} onClick={() => { void runApprovalBatch('mark_reviewed'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedRouteIds.length === 0 || loading} onClick={() => { void runRouteBatch('mark_reviewed'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedRouteIds.length === 0 || loading} onClick={() => { void runRouteBatch('dismiss'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => setSelectedIds([])}></button>
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<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><th></th></tr></thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={8}>{loading ? '加载中...' : '暂无介入事项'}</td></tr>
) : rows.map((row) => (
<tr key={row.intervention_id} className={selectedIds.includes(row.intervention_id) ? 'active' : ''}>
<td><input type="checkbox" checked={selectedIds.includes(row.intervention_id)} onChange={() => toggleRow(row)} aria-label="选择介入事项" /></td>
<td><strong>{row.title}</strong><code>{row.intervention_id}</code></td>
<td><span className={statusClass(row.status)}>{displayText(row.status)}</span></td>
<td>{displayText(row.priority)}</td>
<td><span>{displayText(row.plan_id)}</span><span>{displayText(row.task_id)}</span><span>{displayText(row.run_id)}</span></td>
<td>{displayText(row.reason || row.summary)}</td>
<td>{rowTime(row)}</td>
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
</tr>
))}
</tbody>
</table>
</section>
<div className="de-business-pagination">
<span> {total} </span>
<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>
{detail ? (
<div className="de-artifact-preview" aria-label="介入详情">
<div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div>
<pre>{JSON.stringify(detail, null, 2)}</pre>
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,145 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { Archive, RefreshCw, RotateCcw, Search, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { getDigitalEmployeeMemoryGovernance, setMemoryGovernanceStatus } from '@/lib/api';
import type { DigitalEmployeeMemoryGovernanceRow } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'minmax(180px, 1fr) repeat(3, minmax(120px, 160px)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
type FilterState = { query: string; category: string; status: string; pageSize: number };
const DEFAULT_FILTERS: FilterState = { query: '', category: '', status: '', pageSize: 20 };
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function statusClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (['archived', 'deleted', 'failed'].some((key) => text.includes(key))) return 'de-badge de-badge-danger';
if (['review', 'pending'].some((key) => text.includes(key))) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
export default function MemoryGovernance({ initialQuery, focusEntityId }: { initialQuery?: string | null; focusEntityId?: string | null }) {
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, query: initialQuery || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeMemoryGovernanceRow[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [detail, setDetail] = useState<DigitalEmployeeMemoryGovernanceRow | null>(null);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (initialQuery) setFilters((current) => ({ ...current, query: initialQuery }));
}, [initialQuery]);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeMemoryGovernance({
q: filters.query.trim() || null,
category: filters.category.trim() || null,
status: filters.status.trim() || null,
entity_id: focusEntityId || null,
page_no: pageNo,
page_size: filters.pageSize,
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.memory_id === id)));
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '记忆治理加载失败');
} finally {
setLoading(false);
}
}, [filters, focusEntityId, 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);
};
const toggleRow = (row: DigitalEmployeeMemoryGovernanceRow) => {
setSelectedIds((current) => current.includes(row.memory_id) ? current.filter((id) => id !== row.memory_id) : [...current, row.memory_id]);
};
const runBatch = async (action: 'archive' | 'restore' | 'mark_reviewed') => {
if (selectedIds.length === 0 || loading) return;
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await setMemoryGovernanceStatus(selectedIds, action, { actor: 'digital_employee_operator' });
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '记忆治理操作失败');
setNotice(`记忆治理已记录:${result.recorded}/${result.total}`);
setSelectedIds([]);
await loadRows();
} catch (batchError) {
setError(batchError instanceof Error ? batchError.message : '记忆治理操作失败');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="记忆治理"><span className="de-card-meta-text">{source || 'qimingclaw 长期记忆治理'}</span></CardTitleBar>
<div className="de-card-body">
<div style={toolbarStyle} className="de-business-toolbar">
<label><span></span><input value={filters.query} onChange={(event) => updateFilter('query', event.target.value)} placeholder="key / content" /></label>
<label><span></span><input value={filters.category} onChange={(event) => updateFilter('category', event.target.value)} placeholder="fact" /></label>
<label><span></span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="active" /></label>
<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>
<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>
<div className="de-business-actions" aria-label="记忆批量治理">
<span className="de-card-meta-text"> {selectedIds.length} </span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('archive'); }}><Archive size={14} /> </button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('restore'); }}><RotateCcw size={14} /> </button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('mark_reviewed'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => setSelectedIds([])}></button>
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<section style={tableWrapStyle} aria-label="记忆治理列表">
<table style={tableStyle} className="de-business-table">
<thead><tr><th></th><th>Key / </th><th></th><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={8}>{loading ? '加载中...' : '暂无记忆'}</td></tr> : rows.map((row) => (
<tr key={row.memory_id} className={selectedIds.includes(row.memory_id) ? 'active' : ''}>
<td><input type="checkbox" checked={selectedIds.includes(row.memory_id)} onChange={() => toggleRow(row)} aria-label="选择记忆" /></td>
<td><strong>{displayText(row.key)}</strong><code>{displayText(row.content_preview)}</code></td>
<td><span className={statusClass(row.governance_status)}>{displayText(row.governance_status)}</span></td>
<td>{displayText(row.category)}</td>
<td>{row.merge_candidate_count > 0 ? `重复候选 ${row.merge_candidate_count}` : '-'}</td>
<td>{displayText(row.sync_status)}</td>
<td>{displayText(row.updated_at)}</td>
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
</tr>
))}
</tbody>
</table>
</section>
<div className="de-business-pagination"><span> {total} </span><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>
{detail ? <div className="de-artifact-preview" aria-label="记忆详情"><div><strong>{detail.key}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { RefreshCw, Search, Settings, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { getDigitalEmployeeNotificationWorkbench, replyToNotification, runDigitalEmployeeNotificationBatchAction } from '@/lib/api';
import type { DigitalEmployeeNotificationWorkbenchRow } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(130px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
type FilterState = { kind: string; status: string; level: string; entityId: string; pageSize: number };
const DEFAULT_FILTERS: FilterState = { kind: 'all', status: '', level: '', entityId: '', pageSize: 20 };
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function badgeClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (/error|failed/.test(text)) return 'de-badge de-badge-danger';
if (/warn|action|unread/.test(text)) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
export default function NotificationWorkbench({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, kind: initialKind || 'all', entityId: focusEntityId || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeNotificationWorkbenchRow[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [detail, setDetail] = useState<DigitalEmployeeNotificationWorkbenchRow | null>(null);
const [replyDraft, setReplyDraft] = useState('');
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeNotificationWorkbench({
kind: filters.kind === 'all' ? null : filters.kind,
status: filters.status || null,
level: filters.level || null,
entity_id: filters.entityId || null,
page_no: pageNo,
page_size: filters.pageSize,
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.notification_id === id)));
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '通知运营加载失败');
} finally {
setLoading(false);
}
}, [filters, 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);
};
const toggleRow = (row: DigitalEmployeeNotificationWorkbenchRow) => {
setSelectedIds((current) => current.includes(row.notification_id) ? current.filter((id) => id !== row.notification_id) : [...current, row.notification_id]);
};
const runBatch = async (action: 'mark_read' | 'dismiss' | 'pull_updates' | 'open_desktop_settings') => {
if ((action === 'mark_read' || action === 'dismiss') && selectedIds.length === 0) return;
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await runDigitalEmployeeNotificationBatchAction(selectedIds, action, { actor: 'digital_employee_operator' });
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '通知运营操作失败');
setNotice(action === 'open_desktop_settings' ? '已请求打开系统设置。' : `通知运营已记录:${result.recorded}/${result.total}`);
if (action !== 'open_desktop_settings') setSelectedIds([]);
await loadRows();
} catch (batchError) {
setError(batchError instanceof Error ? batchError.message : '通知运营操作失败');
} finally {
setLoading(false);
}
};
const submitReply = async () => {
if (!detail?.notification_id || !replyDraft.trim()) return;
setLoading(true);
try {
await replyToNotification(detail.notification_id, replyDraft.trim());
setNotice('回复已记录。');
setReplyDraft('');
await loadRows();
} catch (replyError) {
setError(replyError instanceof Error ? replyError.message : '回复失败');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="通知运营"><span className="de-card-meta-text">{source || 'Inbox 运营视图'}</span></CardTitleBar>
<div className="de-card-body">
<div style={toolbarStyle} className="de-business-toolbar">
<label><span></span><input value={filters.kind} onChange={(event) => updateFilter('kind', event.target.value || 'all')} placeholder="all / need_input" /></label>
<label><span></span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="unread" /></label>
<label><span></span><input value={filters.level} onChange={(event) => updateFilter('level', event.target.value)} placeholder="action" /></label>
<label><span></span><input value={filters.entityId} onChange={(event) => updateFilter('entityId', event.target.value)} placeholder="notification id" /></label>
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
</div>
<div className="de-business-actions" aria-label="通知批量动作">
<span className="de-card-meta-text"> {selectedIds.length} </span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('mark_read'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('dismiss'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void runBatch('pull_updates'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void runBatch('open_desktop_settings'); }}><Settings size={14} /> </button>
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<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={row.notification_id} className={selectedIds.includes(row.notification_id) ? 'active' : ''}>
<td><input type="checkbox" checked={selectedIds.includes(row.notification_id)} onChange={() => toggleRow(row)} aria-label="选择通知" /></td>
<td><strong>{row.title}</strong><code>{row.notification_id}</code></td>
<td>{displayText(row.kind)}</td>
<td><span className={badgeClass(row.status)}>{displayText(row.status)}</span></td>
<td><span className={badgeClass(row.priority)}>{displayText(row.priority)}</span></td>
<td>{displayText(row.updated_at || row.occurred_at)}</td>
<td><button type="button" className="de-icon-btn" onClick={() => { setDetail(row); setReplyDraft(''); }} title="详情" aria-label="详情"><Search size={15} /></button></td>
</tr>
))}
</tbody></table></section>
<div className="de-business-pagination"><span> {total} </span><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>
{detail ? <div className="de-artifact-preview" aria-label="通知详情"><div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre><textarea value={replyDraft} onChange={(event) => setReplyDraft(event.target.value)} placeholder="回复单条通知" /><button type="button" className="de-btn-secondary de-btn-sm" disabled={!replyDraft.trim() || loading} onClick={() => { void submitReply(); }}></button></div> : null}
</div>
);
}

View File

@@ -244,7 +244,7 @@ export default function OpsSettings() {
<section className="de-settings-section">
<h3 className="de-settings-heading"></h3>
<p className="de-settings-desc"></p>
<p className="de-settings-desc"></p>
<div style={fieldGridStyle}>
<label style={fieldStyle}>
<span className="de-role-label"></span>

View File

@@ -0,0 +1,137 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { RefreshCw, Search, ShieldCheck, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { getDigitalEmployeeOpsWorkbench, runDigitalEmployeeOpsBatchAction } from '@/lib/api';
import type { DigitalEmployeeOpsWorkbenchRow } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(130px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
type FilterState = { kind: string; status: string; priority: string; entityId: string; pageSize: number };
const DEFAULT_FILTERS: FilterState = { kind: 'all', status: '', priority: '', entityId: '', pageSize: 20 };
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function badgeClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (/error|failed|stopped|degraded/.test(text)) return 'de-badge de-badge-danger';
if (/warn|pending|review/.test(text)) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
export default function OpsWorkbench({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, kind: initialKind || 'all', entityId: focusEntityId || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeOpsWorkbenchRow[]>([]);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [detail, setDetail] = useState<DigitalEmployeeOpsWorkbenchRow | null>(null);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeOpsWorkbench({
kind: filters.kind === 'all' ? null : filters.kind,
status: filters.status || null,
priority: filters.priority || null,
entity_id: filters.entityId || null,
page_no: pageNo,
page_size: filters.pageSize,
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.ops_id === id)));
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '处置台加载失败');
} finally {
setLoading(false);
}
}, [filters, 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);
};
const toggleRow = (row: DigitalEmployeeOpsWorkbenchRow) => {
setSelectedIds((current) => current.includes(row.ops_id) ? current.filter((id) => id !== row.ops_id) : [...current, row.ops_id]);
};
const runBatch = async (action: 'mark_reviewed' | 'dismiss' | 'retry_policy_pull' | 'safe_restart_service') => {
if (selectedIds.length === 0 || loading) return;
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await runDigitalEmployeeOpsBatchAction(selectedIds, action, { actor: 'digital_employee_operator' });
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '处置动作失败');
setNotice(`处置台已记录:${result.recorded}/${result.total}`);
setSelectedIds([]);
await loadRows();
} catch (batchError) {
setError(batchError instanceof Error ? batchError.message : '处置动作失败');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="处置台"><span className="de-card-meta-text">{source || '诊断、服务、同步和策略处置'}</span></CardTitleBar>
<div className="de-card-body">
<div style={toolbarStyle} className="de-business-toolbar">
<label><span></span><select value={filters.kind} onChange={(event) => updateFilter('kind', event.target.value)}><option value="all"></option><option value="diagnostic"></option><option value="service"></option><option value="sync"></option><option value="policy"></option><option value="audit"></option></select></label>
<label><span></span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="error" /></label>
<label><span></span><input value={filters.priority} onChange={(event) => updateFilter('priority', event.target.value)} placeholder="warn" /></label>
<label><span></span><input value={filters.entityId} onChange={(event) => updateFilter('entityId', event.target.value)} placeholder="service / policy" /></label>
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
</div>
<div className="de-business-actions" aria-label="处置台批量动作">
<span className="de-card-meta-text"> {selectedIds.length} </span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('mark_reviewed'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('dismiss'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('retry_policy_pull'); }}></button>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('safe_restart_service'); }}><ShieldCheck size={14} /> </button>
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<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={row.ops_id} className={selectedIds.includes(row.ops_id) ? 'active' : ''}>
<td><input type="checkbox" checked={selectedIds.includes(row.ops_id)} onChange={() => toggleRow(row)} aria-label="选择处置事项" /></td>
<td><strong>{row.title}</strong><code>{row.ops_id}</code></td>
<td>{displayText(row.kind)}</td>
<td><span className={badgeClass(row.status || row.priority)}>{displayText(row.status)}</span></td>
<td>{displayText(row.recommended_action)}</td>
<td>{displayText(row.updated_at || row.occurred_at)}</td>
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
</tr>
))}
</tbody></table>
</section>
<div className="de-business-pagination"><span> {total} </span><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>
{detail ? <div className="de-artifact-preview" aria-label="处置详情"><div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useCallback, useEffect, useState, type CSSProperties } from 'react';
import { RefreshCw } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import {
getDigitalEmployeePolicyCenter,
patchPlanStepDispatchPolicy,
patchRiskApprovalPolicy,
pullDigitalEmployeePoliciesBatch,
} from '@/lib/api';
import type { DigitalEmployeePolicyCenterRow, PlanStepDispatchPolicy, RiskApprovalPolicy } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const gridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 12 };
const panelStyle: CSSProperties = { border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8, padding: 12, display: 'grid', gap: 10 };
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function badgeClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (/error|failed|disabled/.test(text)) return 'de-badge de-badge-danger';
if (/pull|pending|remote/.test(text)) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
export default function PolicyCenter() {
const [rows, setRows] = useState<DigitalEmployeePolicyCenterRow[]>([]);
const [dispatchPolicy, setDispatchPolicy] = useState<PlanStepDispatchPolicy | null>(null);
const [riskPolicy, setRiskPolicy] = useState<RiskApprovalPolicy | null>(null);
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadPolicyCenter = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeePolicyCenter();
setRows(data.items ?? []);
setDispatchPolicy(data.plan_step_dispatch_policy);
setRiskPolicy(data.risk_approval_policy);
} catch (loadError) {
setRows([]);
setError(loadError instanceof Error ? loadError.message : '策略中心加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void loadPolicyCenter(); }, [loadPolicyCenter]);
const pullAll = async () => {
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await pullDigitalEmployeePoliciesBatch();
if (!result.ok) throw new Error(result.items.map((item) => item.error).filter(Boolean).join(', ') || '批量拉取策略失败');
setNotice(`批量拉取策略完成:${result.pulled}/${result.total}`);
await loadPolicyCenter();
} catch (pullError) {
setError(pullError instanceof Error ? pullError.message : '批量拉取策略失败');
} finally {
setLoading(false);
}
};
const updateDispatch = async (patch: Partial<PlanStepDispatchPolicy>) => {
if (!dispatchPolicy) return;
const next = await patchPlanStepDispatchPolicy(patch);
setDispatchPolicy(next);
setNotice('派发策略已保存。');
await loadPolicyCenter();
};
const updateRisk = async (patch: Partial<RiskApprovalPolicy>) => {
if (!riskPolicy) return;
const next = await patchRiskApprovalPolicy(patch);
setRiskPolicy(next);
setNotice('风险审批策略已保存。');
await loadPolicyCenter();
};
const updateRiskCsv = (field: 'approval_required_risk_levels' | 'approval_required_actions' | 'auto_reject_actions', value: string) => {
void updateRisk({ [field]: value.split(',').map((item) => item.trim()).filter(Boolean) } as Partial<RiskApprovalPolicy>);
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="策略中心">
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void pullAll(); }}></button>
<button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void loadPolicyCenter(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
</CardTitleBar>
<div className="de-card-body">
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<div style={gridStyle}>
{rows.map((row) => <section key={row.policy_id} style={panelStyle}><strong>{row.title}</strong><span className={badgeClass(row.status)}>{row.status}</span><small>{displayText(row.summary)}</small><small>source: {displayText(row.source)} · pulled: {displayText(row.last_pulled_at)}</small>{row.last_pull_error ? <small>{row.last_pull_error}</small> : null}</section>)}
</div>
{dispatchPolicy ? <section style={panelStyle}>
<h3 className="de-settings-heading"></h3>
<label><span>enabled</span><select value={dispatchPolicy.enabled ? 'true' : 'false'} onChange={(event) => { void updateDispatch({ enabled: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
<label><span>max_per_sweep</span><input type="number" min={1} max={10} value={dispatchPolicy.max_per_sweep} onChange={(event) => { void updateDispatch({ max_per_sweep: Number(event.target.value) || 1 }); }} /></label>
<label><span>auto_dispatch_ready_steps</span><select value={dispatchPolicy.auto_dispatch_ready_steps ? 'true' : 'false'} onChange={(event) => { void updateDispatch({ auto_dispatch_ready_steps: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
</section> : null}
{riskPolicy ? <section style={panelStyle}>
<h3 className="de-settings-heading"></h3>
<label><span>enabled</span><select value={riskPolicy.enabled ? 'true' : 'false'} onChange={(event) => { void updateRisk({ enabled: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
<label><span>approval_required_risk_levels</span><input value={riskPolicy.approval_required_risk_levels.join(',')} onChange={(event) => updateRiskCsv('approval_required_risk_levels', event.target.value)} /></label>
<label><span>approval_required_actions</span><input value={riskPolicy.approval_required_actions.join(',')} onChange={(event) => updateRiskCsv('approval_required_actions', event.target.value)} /></label>
<label><span>auto_reject_actions</span><input value={riskPolicy.auto_reject_actions.join(',')} onChange={(event) => updateRiskCsv('auto_reject_actions', event.target.value)} /></label>
</section> : null}
</div>
</GlassCard>
</div>
);
}

View File

@@ -0,0 +1,138 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { Play, RefreshCw, Trash2 } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { deleteCronJob, getDigitalEmployeeSchedulerWorkbench, patchCronJob, patchCronSettings, triggerCronCheck } from '@/lib/api';
import type { DigitalEmployeeSchedulerWorkbenchRow } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
const settingsStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10, marginBottom: 12 };
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function badgeClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (/failed|error|deleted/.test(text)) return 'de-badge de-badge-danger';
if (/paused|pending|skipped/.test(text)) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
export default function SchedulerWorkbench() {
const [rows, setRows] = useState<DigitalEmployeeSchedulerWorkbenchRow[]>([]);
const [settings, setSettings] = useState({ enabled: false, catch_up_on_startup: false, max_run_history: 20 });
const [loading, setLoading] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeSchedulerWorkbench();
setRows(data.items ?? []);
setSettings(data.settings);
} catch (loadError) {
setRows([]);
setError(loadError instanceof Error ? loadError.message : '调度运营加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { void loadRows(); }, [loadRows]);
const enabledCount = useMemo(() => rows.filter((row) => row.enabled).length, [rows]);
const updateSettings = async (patch: Partial<typeof settings>) => {
setLoading(true);
try {
const next = await patchCronSettings(patch);
setSettings(next);
setNotice('调度设置已保存。');
await loadRows();
} catch (settingsError) {
setError(settingsError instanceof Error ? settingsError.message : '调度设置保存失败');
} finally {
setLoading(false);
}
};
const checkNow = async () => {
setLoading(true);
setNotice(null);
setError(null);
try {
const result = await triggerCronCheck();
setNotice(`立即检查调度完成activated ${result.activated ?? 0}skipped ${result.skipped ?? 0}`);
await loadRows();
} catch (checkError) {
setError(checkError instanceof Error ? checkError.message : '立即检查调度失败');
} finally {
setLoading(false);
}
};
const toggleJob = async (row: DigitalEmployeeSchedulerWorkbenchRow) => {
setLoading(true);
try {
await patchCronJob(row.schedule_id, { enabled: !row.enabled });
setNotice(row.enabled ? '调度已暂停。' : '调度已启用。');
await loadRows();
} catch (toggleError) {
setError(toggleError instanceof Error ? toggleError.message : '调度启停失败');
} finally {
setLoading(false);
}
};
const removeJob = async (row: DigitalEmployeeSchedulerWorkbenchRow) => {
setLoading(true);
try {
await deleteCronJob(row.schedule_id);
setNotice('调度已删除。');
await loadRows();
} catch (deleteError) {
setError(deleteError instanceof Error ? deleteError.message : '删除调度失败');
} finally {
setLoading(false);
}
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="调度运营">
<span className="de-card-meta-text"> {enabledCount}/{rows.length}</span>
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void checkNow(); }}><Play size={14} /> </button>
<button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
</CardTitleBar>
<div className="de-card-body">
<div style={settingsStyle} className="de-business-toolbar">
<label><span>enabled</span><select value={settings.enabled ? 'true' : 'false'} onChange={(event) => { void updateSettings({ enabled: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
<label><span>catch_up_on_startup</span><select value={settings.catch_up_on_startup ? 'true' : 'false'} onChange={(event) => { void updateSettings({ catch_up_on_startup: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
<label><span>max_run_history</span><input type="number" min={1} max={500} value={settings.max_run_history} onChange={(event) => { void updateSettings({ max_run_history: Number(event.target.value) || 20 }); }} /></label>
</div>
{notice ? <p className="de-inline-notice">{notice}</p> : null}
{error ? <p className="de-inline-notice">{error}</p> : null}
<section style={tableWrapStyle} aria-label="调度运营列表"><table style={tableStyle} className="de-business-table"><thead><tr><th></th><th></th><th>Cron</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={row.schedule_id}>
<td><strong>{row.name}</strong><code>{row.schedule_id}</code></td>
<td><span className={badgeClass(row.status)}>{displayText(row.status)}</span></td>
<td>{displayText(row.cron_expr)}</td>
<td>{displayText(row.next_run)}</td>
<td>{displayText(row.last_run)}</td>
<td>{displayText(row.last_status || row.latest_run_error)}</td>
<td className="de-business-actions"><button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void toggleJob(row); }}>{row.enabled ? '暂停' : '启用'}</button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void removeJob(row); }} title="删除" aria-label="删除"><Trash2 size={15} /></button></td>
</tr>
))}
</tbody></table></section>
</div>
</GlassCard>
</div>
);
}

View File

@@ -0,0 +1,163 @@
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
import { Filter, RefreshCw, Search, X } from 'lucide-react';
import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { getDigitalEmployeeSkillAuditWorkbench } from '@/lib/api';
import type { DigitalEmployeeSkillAuditWorkbenchRow } from '@/types/api';
const pageStyle: CSSProperties = { padding: '14px 18px' };
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(6, minmax(120px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
const tableStyle: CSSProperties = { width: '100%', minWidth: 1040, borderCollapse: 'collapse' };
type FilterState = {
skillId: string;
toolName: string;
serverId: string;
decision: string;
category: string;
status: string;
pageSize: number;
};
const DEFAULT_FILTERS: FilterState = {
skillId: '',
toolName: '',
serverId: '',
decision: '',
category: '',
status: '',
pageSize: 20,
};
function displayText(value: unknown, fallback = '-'): string {
if (value === null || value === undefined || value === '') return fallback;
return String(value);
}
function decisionLabel(value: unknown): string {
const text = String(value || '').toLowerCase();
if (text.includes('allow') || text.includes('accepted')) return '允许';
if (text.includes('reject') || text.includes('deny') || text.includes('blocked')) return '拒绝';
if (text.includes('observe')) return '观察';
return displayText(value, '观察');
}
function badgeClass(value: unknown): string {
const text = String(value || '').toLowerCase();
if (/reject|deny|blocked|failed|error/.test(text)) return 'de-badge de-badge-danger';
if (/warn|review|pending|timeout/.test(text)) return 'de-badge de-badge-warning';
return 'de-badge de-badge-info';
}
function formatCost(value: unknown): string {
const numberValue = typeof value === 'number' ? value : Number(value);
if (!Number.isFinite(numberValue) || numberValue <= 0) return '-';
if (numberValue < 0.01) return `$${numberValue.toFixed(4)}`;
return `$${numberValue.toFixed(2)}`;
}
export default function SkillAuditWorkbench({ focusSkillId }: { focusSkillId?: string | null }) {
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, skillId: focusSkillId || '' });
const [pageNo, setPageNo] = useState(1);
const [rows, setRows] = useState<DigitalEmployeeSkillAuditWorkbenchRow[]>([]);
const [total, setTotal] = useState(0);
const [source, setSource] = useState<string | null>(null);
const [detail, setDetail] = useState<DigitalEmployeeSkillAuditWorkbenchRow | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (focusSkillId) setFilters((current) => ({ ...current, skillId: focusSkillId }));
}, [focusSkillId]);
const loadRows = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await getDigitalEmployeeSkillAuditWorkbench({
skill_id: filters.skillId.trim() || null,
tool_name: filters.toolName.trim() || null,
server_id: filters.serverId.trim() || null,
decision: filters.decision.trim() || null,
category: filters.category.trim() || null,
status: filters.status.trim() || null,
page_no: pageNo,
page_size: filters.pageSize,
});
setRows(data.items ?? []);
setTotal(data.total ?? 0);
setSource(data.source ?? null);
} catch (loadError) {
setRows([]);
setTotal(0);
setError(loadError instanceof Error ? loadError.message : '技能审计加载失败');
} finally {
setLoading(false);
}
}, [filters, 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);
};
const resetFilters = () => {
setFilters({ ...DEFAULT_FILTERS, skillId: focusSkillId || '' });
setPageNo(1);
};
return (
<div style={pageStyle}>
<GlassCard>
<CardTitleBar title="技能审计">
<span className="de-card-meta-text">{source || '技能调用、工具、sandbox 与 token/cost 审计'}</span>
</CardTitleBar>
<div className="de-card-body">
<div style={toolbarStyle} className="de-business-toolbar">
<label><span>Skill</span><input value={filters.skillId} onChange={(event) => updateFilter('skillId', event.target.value)} placeholder="skill id" /></label>
<label><span>Tool</span><input value={filters.toolName} onChange={(event) => updateFilter('toolName', event.target.value)} placeholder="tool name" /></label>
<label><span>Server</span><input value={filters.serverId} onChange={(event) => updateFilter('serverId', event.target.value)} placeholder="server id" /></label>
<label><span></span><input value={filters.decision} onChange={(event) => updateFilter('decision', event.target.value)} placeholder="允许 / 拒绝 / 观察" /></label>
<label><span></span><input value={filters.category} onChange={(event) => updateFilter('category', event.target.value)} placeholder="tool_call_audit" /></label>
<label><span></span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="status" /></label>
<div className="de-business-actions">
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="筛选" aria-label="筛选"><Filter size={15} /></button>
<button type="button" className="de-icon-btn" onClick={resetFilters} title="重置" aria-label="重置"><RefreshCw size={15} /></button>
</div>
</div>
<div className="de-business-actions" aria-label="技能审计筛选">
<span className="de-card-meta-text"> {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>
</div>
{error ? <p className="de-inline-notice">{error}</p> : null}
<section style={tableWrapStyle} aria-label="技能审计列表">
<table style={tableStyle} className="de-business-table">
<thead><tr><th></th><th></th><th>Skill / Tool</th><th>Server</th><th></th><th>Token / </th><th></th><th></th></tr></thead>
<tbody>
{rows.length === 0 ? <tr><td colSpan={8}>{loading ? '加载中...' : '暂无技能审计记录'}</td></tr> : rows.map((row) => (
<tr key={row.audit_id}>
<td><strong>{displayText(row.category)}</strong><code>{displayText(row.audit_id)}</code></td>
<td><span className={badgeClass(row.decision || row.status)}>{decisionLabel(row.decision || row.status)}</span></td>
<td><strong>{displayText(row.skill_id)}</strong><code>{displayText(row.tool_name)}</code></td>
<td>{displayText(row.server_id)}</td>
<td><span className={badgeClass(row.risk_level || row.status)}>{displayText(row.risk_level || row.status)}</span></td>
<td>{displayText(row.total_tokens, '0')} / {formatCost(row.estimated_cost_usd)}</td>
<td>{displayText(row.occurred_at || row.updated_at)}</td>
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
</tr>
))}
</tbody>
</table>
</section>
<div className="de-business-pagination"><span>{pageNo} / {pageCount}</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}></button><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>
{detail ? <div className="de-artifact-preview" aria-label="技能审计详情"><div><strong>{detail.category}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
</div>
);
}

View File

@@ -3,7 +3,7 @@ import GlassCard from '@/components/digital/GlassCard';
import CardTitleBar from '@/components/digital/CardTitleBar';
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
import { getSkills, pullSkillPolicies, setSkillEnabled } from '@/lib/api';
import { domSafeId } from './navigation';
import { domSafeId, type DigitalNavigationTarget } from './navigation';
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
type SkillSection = {
@@ -107,6 +107,7 @@ function SkillCard({
updating = false,
onSelect,
onToggle,
onAudit,
}: {
skill: SkillSummary;
recommended?: boolean;
@@ -115,6 +116,7 @@ function SkillCard({
updating?: boolean;
onSelect?: (skill: SkillSummary) => void;
onToggle?: (skill: SkillSummary) => void;
onAudit?: (skill: SkillSummary) => void;
}) {
const cadence = getCadence(skill);
const status = learningStatus(skill, recommended, advanced);
@@ -163,12 +165,22 @@ function SkillCard({
>
{updating ? '处理中...' : skill.enabled ? '暂不使用' : '恢复使用'}
</button>
<button
className="de-skill-btn"
title="查看该技能的调用审计。"
onClick={(event) => {
event.stopPropagation();
onAudit?.(skill);
}}
>
</button>
</div>
</div>
);
}
export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string | null }) {
export default function SkillLibrary({ focusSkillId, onNavigate }: { focusSkillId?: string | null; onNavigate?: (target: DigitalNavigationTarget) => void }) {
const [skills, setSkills] = useState<SkillSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
@@ -332,6 +344,7 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
updating={updatingSkillId === s.id}
onSelect={(skill) => setSelectedSkillId(skill.id)}
onToggle={toggleSkill}
onAudit={(skill) => onNavigate?.({ tab: 'skill-audit', auditSkillId: skill.id })}
/>
))}
</div>

View File

@@ -1,4 +1,4 @@
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'business' | 'settings';
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'business' | 'interventions' | 'memory-governance' | 'skill-audit' | 'ops-workbench' | 'notification-workbench' | 'daily-report-workbench' | 'policy-center' | 'scheduler-workbench' | 'settings';
export type DigitalNavigationTarget = {
tab: DigitalTab;
@@ -8,6 +8,11 @@ export type DigitalNavigationTarget = {
skillId?: string | null;
reportSection?: string | null;
businessKind?: string | null;
interventionKind?: string | null;
memoryQuery?: string | null;
auditSkillId?: string | null;
opsKind?: string | null;
notificationKind?: string | null;
entityId?: string | null;
};

View File

@@ -332,7 +332,231 @@ export interface BusinessViewResponse<T> {
source?: string;
}
export type DigitalEmployeeBusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals';
export type DigitalEmployeeInterventionKind = 'approval_conflict' | 'route_intervention' | 'risk_approval' | 'action_notification' | string;
export interface DigitalEmployeeInterventionRow extends Record<string, unknown> {
intervention_id: string;
kind: DigitalEmployeeInterventionKind;
title: string;
status: string;
priority: 'normal' | 'medium' | 'high' | string;
entity_type?: string | null;
entity_id?: string | null;
approval_id?: string | null;
route_decision_id?: string | null;
notification_id?: string | null;
event_id?: string | null;
plan_id?: string | null;
task_id?: string | null;
run_id?: string | null;
reason?: string | null;
summary?: string | null;
occurred_at?: string | null;
updated_at?: string | null;
payload?: Record<string, unknown> | null;
}
export interface DigitalEmployeeInterventionWorkbenchResponse {
items: DigitalEmployeeInterventionRow[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export interface DigitalEmployeeBatchActionResult {
ok: boolean;
total: number;
recorded: number;
rejected: number;
items: Array<Record<string, unknown>>;
reason_codes?: string[];
error?: string | null;
}
export interface DigitalEmployeeMemoryGovernanceRow extends Record<string, unknown> {
memory_id: string;
key: string;
category: string;
source?: string | null;
status: string;
governance_status: string;
last_governance_action?: string | null;
last_reviewed_at?: string | null;
content_preview: string;
score?: number | null;
session_id?: string | null;
sync_status?: string | null;
merge_candidate_count: number;
duplicate_memory_ids: string[];
latest_memory_id?: string | null;
updated_at?: string | null;
}
export interface DigitalEmployeeMemoryGovernanceResponse {
items: DigitalEmployeeMemoryGovernanceRow[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export interface DigitalEmployeeSkillAuditWorkbenchRow extends Record<string, unknown> {
audit_id: string;
category: string;
decision?: string | null;
status: string;
skill_id?: string | null;
tool_name?: string | null;
server_id?: string | null;
source?: string | null;
risk_level?: string | null;
model?: string | null;
total_tokens?: number | null;
estimated_cost_usd?: number | null;
session_id?: string | null;
task_id?: string | null;
run_id?: string | null;
message?: string | null;
occurred_at?: string | null;
updated_at?: string | null;
}
export interface DigitalEmployeeSkillAuditWorkbenchResponse {
items: DigitalEmployeeSkillAuditWorkbenchRow[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export interface DigitalEmployeeWorkbenchBatchResult {
ok: boolean;
action: string;
total: number;
recorded: number;
rejected: number;
items: Array<Record<string, unknown>>;
reason_codes?: string[];
error?: string | null;
}
export interface DigitalEmployeeOpsWorkbenchRow extends Record<string, unknown> {
ops_id: string;
kind: 'diagnostic' | 'service' | 'sync' | 'policy' | 'audit' | string;
title: string;
status: string;
priority: 'info' | 'warn' | 'error' | 'action' | string;
entity_id?: string | null;
service_id?: string | null;
recommended_action?: string | null;
reason_codes?: string[];
occurred_at?: string | null;
updated_at?: string | null;
}
export interface DigitalEmployeeOpsWorkbenchResponse {
items: DigitalEmployeeOpsWorkbenchRow[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export interface DigitalEmployeeNotificationWorkbenchRow extends Record<string, unknown> {
notification_id: string;
kind: string;
title: string;
status: string;
priority: string;
level?: string | null;
entity_id?: string | null;
recommended_action?: string | null;
occurred_at?: string | null;
updated_at?: string | null;
}
export interface DigitalEmployeeNotificationWorkbenchResponse {
items: DigitalEmployeeNotificationWorkbenchRow[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export interface DigitalEmployeeDailyReportWorkbenchRow extends Record<string, unknown> {
report_id: string;
date: string;
title: string;
status: string;
priority: string;
delivery_status?: string | null;
confirmation_status?: string | null;
approval_status?: string | null;
artifact_count?: number | null;
recommended_action?: string | null;
occurred_at?: string | null;
updated_at?: string | null;
}
export interface DigitalEmployeeDailyReportWorkbenchResponse {
items: DigitalEmployeeDailyReportWorkbenchRow[];
page_no: number;
page_size: number;
total: number;
source?: string;
}
export interface DigitalEmployeePolicyCenterRow extends Record<string, unknown> {
policy_id: 'plan_step_dispatch' | 'risk_approval' | 'route_decision' | 'skill' | string;
title: string;
status: string;
source?: string | null;
updated_at?: string | null;
last_pulled_at?: string | null;
last_pull_error?: string | null;
summary?: string | null;
}
export interface DigitalEmployeePolicyCenterResponse {
items: DigitalEmployeePolicyCenterRow[];
plan_step_dispatch_policy: PlanStepDispatchPolicy;
risk_approval_policy: RiskApprovalPolicy;
source?: string;
}
export interface DigitalEmployeePolicyPullBatchResult {
ok: boolean;
total: number;
pulled: number;
failed: number;
items: Array<{ policy_id: string; ok: boolean; error?: string | null; result?: unknown }>;
}
export interface DigitalEmployeeSchedulerWorkbenchRow extends Record<string, unknown> {
schedule_id: string;
name: string;
enabled: boolean;
status: string;
cron_expr?: string | null;
next_run?: string | null;
last_run?: string | null;
last_status?: string | null;
latest_run_error?: string | null;
run_count?: number;
}
export interface DigitalEmployeeSchedulerWorkbenchResponse {
items: DigitalEmployeeSchedulerWorkbenchRow[];
settings: {
enabled: boolean;
catch_up_on_startup: boolean;
max_run_history: number;
};
source?: string;
}
export type DigitalEmployeeBusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'artifact-groups' | 'approvals';
export interface DigitalEmployeeBusinessViewQuery {
entity_id?: string | null;
@@ -352,6 +576,7 @@ export interface DigitalEmployeeBusinessViewRow extends Record<string, unknown>
run_id?: string | null;
event_id?: string | null;
artifact_id?: string | null;
artifact_ids?: string[] | null;
approval_id?: string | null;
title?: string | null;
status?: string | null;
@@ -367,6 +592,13 @@ export interface DigitalEmployeeBusinessViewRow extends Record<string, unknown>
approval_conflict_resolution_event_id?: string | null;
task_count?: number | null;
run_count?: number | null;
artifact_count?: number | null;
device_count?: number | null;
version_count?: number | null;
latest_artifact_id?: string | null;
retention_status_counts?: Record<string, number> | null;
cleanup_status_counts?: Record<string, number> | null;
version_key?: string | null;
latest_event_at?: string | null;
last_event_message?: string | null;
occurred_at?: string | null;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -16,7 +16,7 @@
console.warn("[qimingclaw] digital employee auth bootstrap skipped", error);
}
</script>
<script type="module" crossorigin src="./assets/index-Bh0zKhVB.js"></script>
<script type="module" crossorigin src="./assets/index-DUKd5ydN.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-D7Rysr2C.css">
</head>
<body>

View File

@@ -848,6 +848,43 @@ function checkDigitalArtifactCleanup() {
console.log("[DigitalEmployeeCheck] artifact cleanup execution hooks OK");
}
function checkDigitalArtifactBatchGovernance() {
console.log("\n[DigitalEmployeeCheck] Verify artifact batch governance hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const businessViewPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "BusinessView.tsx");
const businessView = fs.existsSync(businessViewPath) ? fs.readFileSync(businessViewPath, "utf8") : "";
const dailyReportPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DailyReport.tsx");
const dailyReport = fs.readFileSync(dailyReportPath, "utf8");
const docs = fs.readFileSync(path.join(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md"), "utf8");
const requiredSnippets = [
[adapter, "handleArtifactRetentionBatch", "adapter artifact retention batch handler"],
[adapter, "/retention/batch", "adapter artifact retention batch endpoint"],
[adapter, "artifactGroupRows", "adapter artifact aggregate rows"],
[adapter, "/artifact-groups", "adapter artifact groups endpoint"],
[api, "setArtifactRetentionBatch", "embedded artifact retention batch API"],
[types, "artifact-groups", "business view artifact groups kind"],
[types, "artifact_count", "artifact aggregate count type field"],
[types, "device_count", "artifact aggregate device count type field"],
[businessView, "产物聚合", "business view artifact groups tab"],
[businessView, "批量保留", "business view batch retention UI"],
[dailyReport, "批量标记到期", "daily report batch retention action"],
[docs, "保留策略批量治理", "docs artifact batch governance absorption"],
[docs, "跨设备聚合视图", "docs artifact aggregate view absorption"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing artifact batch governance hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] artifact batch governance hooks OK");
}
function checkDigitalBusinessViewUi() {
console.log("\n[DigitalEmployeeCheck] Verify management business view UI hooks");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
@@ -892,6 +929,226 @@ function checkDigitalBusinessViewUi() {
console.log("[DigitalEmployeeCheck] management business view UI hooks OK");
}
function checkDigitalInterventionWorkbench() {
console.log("\n[DigitalEmployeeCheck] Verify intervention workbench hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const shellPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DigitalEmployeeShell.tsx");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const workbenchPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "InterventionWorkbench.tsx");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const shell = fs.readFileSync(shellPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const workbench = fs.existsSync(workbenchPath) ? fs.readFileSync(workbenchPath, "utf8") : "";
const docs = fs.readFileSync(path.join(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md"), "utf8");
const requiredSnippets = [
[adapter, "interventionWorkbenchRows", "adapter intervention workbench projection"],
[adapter, "/api/digital-employee/interventions/workbench", "adapter intervention workbench endpoint"],
[adapter, "handleApprovalConflictResolutionBatch", "adapter approval conflict batch handler"],
[adapter, "batch_accept_remote_disabled", "adapter approval batch safety guard"],
[adapter, "handleRouteInterventionsBatch", "adapter route intervention batch handler"],
[api, "getDigitalEmployeeInterventionWorkbench", "embedded intervention workbench API"],
[api, "resolveApprovalConflictsBatch", "embedded approval conflict batch API"],
[api, "decideRouteInterventionsBatch", "embedded route intervention batch API"],
[types, "DigitalEmployeeInterventionRow", "intervention row type"],
[types, "DigitalEmployeeInterventionWorkbenchResponse", "intervention response type"],
[shell, "InterventionWorkbench", "intervention shell component"],
[shell, "介入台", "intervention tab label"],
[commandDeck, "打开介入台", "command deck intervention entry"],
[workbench, "批量保留本地", "workbench keep local batch UI"],
[workbench, "批量标记复核", "workbench mark reviewed batch UI"],
[workbench, "批量忽略", "workbench route dismiss batch UI"],
[docs, "跨设备批量冲突排查", "docs approval intervention absorption"],
[docs, "批量干预入口", "docs route intervention absorption"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing intervention workbench hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] intervention workbench hooks OK");
}
function checkDigitalMemoryGovernanceAudit() {
console.log("\n[DigitalEmployeeCheck] Verify memory governance and skill audit hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const shellPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DigitalEmployeeShell.tsx");
const skillLibraryPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "SkillLibrary.tsx");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const memoryGovernancePath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "MemoryGovernance.tsx");
const skillAuditPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "SkillAuditWorkbench.tsx");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const shell = fs.readFileSync(shellPath, "utf8");
const skillLibrary = fs.readFileSync(skillLibraryPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const memoryGovernance = fs.existsSync(memoryGovernancePath) ? fs.readFileSync(memoryGovernancePath, "utf8") : "";
const skillAudit = fs.existsSync(skillAuditPath) ? fs.readFileSync(skillAuditPath, "utf8") : "";
const docs = fs.readFileSync(path.join(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md"), "utf8");
const requiredSnippets = [
[adapter, "memoryGovernanceRows", "adapter memory governance projection"],
[adapter, "/api/digital-employee/memory-governance", "adapter memory governance endpoint"],
[adapter, "/api/digital-employee/memory-governance/status/batch", "adapter memory governance batch endpoint"],
[adapter, "skillAuditWorkbenchRows", "adapter skill audit workbench projection"],
[adapter, "/api/digital-employee/skill-audit-workbench", "adapter skill audit workbench endpoint"],
[api, "getDigitalEmployeeMemoryGovernance", "embedded memory governance API"],
[api, "setMemoryGovernanceStatus", "embedded memory governance batch API"],
[api, "getDigitalEmployeeSkillAuditWorkbench", "embedded skill audit API"],
[types, "DigitalEmployeeMemoryGovernanceRow", "memory governance row type"],
[types, "DigitalEmployeeSkillAuditWorkbenchRow", "skill audit row type"],
[shell, "MemoryGovernance", "memory governance shell component"],
[shell, "SkillAuditWorkbench", "skill audit shell component"],
[shell, "记忆治理", "memory governance tab label"],
[shell, "技能审计", "skill audit tab label"],
[skillLibrary, "技能审计", "skill library audit entry"],
[commandDeck, "打开技能审计", "home skill audit entry"],
[memoryGovernance, "批量归档", "memory governance archive UI"],
[memoryGovernance, "批量恢复", "memory governance restore UI"],
[memoryGovernance, "重复候选", "memory governance duplicate candidate UI"],
[skillAudit, "技能审计", "skill audit workbench title"],
[docs, "记忆归档/恢复 UI", "docs memory governance absorption"],
[docs, "管理端调用审计视图", "docs skill audit absorption"],
[docs, "管理端正式审计视图预览", "docs audit preview absorption"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing memory governance and skill audit hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] memory governance and skill audit hooks OK");
}
function checkDigitalOpsNotificationReportWorkbenches() {
console.log("\n[DigitalEmployeeCheck] Verify ops, notification and daily report workbench hooks");
const adapterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "qimingclawAdapter.ts");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const shellPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DigitalEmployeeShell.tsx");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const businessViewPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "BusinessView.tsx");
const opsWorkbenchPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "OpsWorkbench.tsx");
const notificationWorkbenchPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "NotificationWorkbench.tsx");
const dailyReportWorkbenchPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DailyReportWorkbench.tsx");
const adapter = fs.readFileSync(adapterPath, "utf8");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const shell = fs.readFileSync(shellPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const businessView = fs.readFileSync(businessViewPath, "utf8");
const opsWorkbench = fs.existsSync(opsWorkbenchPath) ? fs.readFileSync(opsWorkbenchPath, "utf8") : "";
const notificationWorkbench = fs.existsSync(notificationWorkbenchPath) ? fs.readFileSync(notificationWorkbenchPath, "utf8") : "";
const dailyReportWorkbench = fs.existsSync(dailyReportWorkbenchPath) ? fs.readFileSync(dailyReportWorkbenchPath, "utf8") : "";
const docs = fs.readFileSync(path.join(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md"), "utf8");
const requiredSnippets = [
[adapter, "opsWorkbenchRows", "adapter ops workbench projection"],
[adapter, "/api/digital-employee/ops-workbench", "adapter ops workbench endpoint"],
[adapter, "/api/digital-employee/ops-workbench/actions/batch", "adapter ops batch endpoint"],
[adapter, "safe_restart_service", "adapter safe restart action"],
[adapter, "ops_workbench_action_recorded", "adapter ops audit action"],
[adapter, "/api/digital-employee/notifications/workbench", "adapter notification workbench endpoint"],
[adapter, "/api/digital-employee/notifications/actions/batch", "adapter notification batch endpoint"],
[adapter, "notification_workbench_action_recorded", "adapter notification audit action"],
[adapter, "/api/digital-employee/daily-reports/workbench", "adapter daily report workbench endpoint"],
[adapter, "daily_report_workbench_action_recorded", "adapter daily report audit action"],
[api, "getDigitalEmployeeOpsWorkbench", "embedded ops workbench API"],
[api, "runDigitalEmployeeOpsBatchAction", "embedded ops batch API"],
[api, "getDigitalEmployeeNotificationWorkbench", "embedded notification workbench API"],
[api, "runDigitalEmployeeNotificationBatchAction", "embedded notification batch API"],
[api, "getDigitalEmployeeDailyReportWorkbench", "embedded daily report workbench API"],
[types, "DigitalEmployeeOpsWorkbenchRow", "ops workbench row type"],
[types, "DigitalEmployeeNotificationWorkbenchRow", "notification workbench row type"],
[types, "DigitalEmployeeDailyReportWorkbenchRow", "daily report workbench row type"],
[shell, "OpsWorkbench", "ops workbench shell component"],
[shell, "NotificationWorkbench", "notification workbench shell component"],
[shell, "DailyReportWorkbench", "daily report workbench shell component"],
[shell, "处置台", "ops workbench tab label"],
[shell, "通知运营", "notification workbench tab label"],
[shell, "日报运营", "daily report workbench tab label"],
[commandDeck, "打开处置台", "home ops entry"],
[commandDeck, "打开通知运营", "home notification entry"],
[commandDeck, "打开日报运营", "home daily report entry"],
[businessView, "打开处置台", "business view ops related link"],
[opsWorkbench, "安全重启", "ops safe restart UI"],
[opsWorkbench, "策略重拉", "ops policy retry UI"],
[notificationWorkbench, "批量已读", "notification batch read UI"],
[notificationWorkbench, "打开系统设置", "notification settings UI"],
[dailyReportWorkbench, "请求审批", "daily report approval UI"],
[dailyReportWorkbench, "确认回执", "daily report confirmation UI"],
[docs, "完整人机介入入口", "docs managed service ops absorption"],
[docs, "真实管理端通知页面预览", "docs notification workbench absorption"],
[docs, "诊断修复动作预览", "docs diagnostic repair absorption"],
[docs, "管理端正式日报页面预览", "docs daily report workbench absorption"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing ops/notification/daily report workbench hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] ops, notification and daily report workbench hooks OK");
}
function checkDigitalPolicySchedulerWorkbenches() {
console.log("\n[DigitalEmployeeCheck] Verify policy center and scheduler workbench hooks");
const apiPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "lib", "api.ts");
const typesPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "types", "api.ts");
const shellPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "DigitalEmployeeShell.tsx");
const commandDeckPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "CommandDeck.tsx");
const opsSettingsPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "OpsSettings.tsx");
const policyCenterPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "PolicyCenter.tsx");
const schedulerWorkbenchPath = path.join(packageRoot, "embedded", "sgrobot-digital", "src", "pages", "digital", "SchedulerWorkbench.tsx");
const api = fs.readFileSync(apiPath, "utf8");
const types = fs.readFileSync(typesPath, "utf8");
const shell = fs.readFileSync(shellPath, "utf8");
const commandDeck = fs.readFileSync(commandDeckPath, "utf8");
const opsSettings = fs.readFileSync(opsSettingsPath, "utf8");
const policyCenter = fs.existsSync(policyCenterPath) ? fs.readFileSync(policyCenterPath, "utf8") : "";
const schedulerWorkbench = fs.existsSync(schedulerWorkbenchPath) ? fs.readFileSync(schedulerWorkbenchPath, "utf8") : "";
const docs = fs.readFileSync(path.join(packageRoot, "..", "..", "docs", "digital-employee-frontend-integration-plan.md"), "utf8");
const requiredSnippets = [
[api, "getDigitalEmployeePolicyCenter", "embedded policy center API"],
[api, "pullDigitalEmployeePoliciesBatch", "embedded policy batch pull API"],
[api, "getDigitalEmployeeSchedulerWorkbench", "embedded scheduler workbench API"],
[types, "DigitalEmployeePolicyCenterRow", "policy center row type"],
[types, "DigitalEmployeePolicyCenterResponse", "policy center response type"],
[types, "DigitalEmployeeSchedulerWorkbenchRow", "scheduler workbench row type"],
[types, "DigitalEmployeeSchedulerWorkbenchResponse", "scheduler workbench response type"],
[types, "DigitalEmployeePolicyPullBatchResult", "policy pull batch result type"],
[shell, "PolicyCenter", "policy center shell component"],
[shell, "SchedulerWorkbench", "scheduler workbench shell component"],
[shell, "策略中心", "policy center tab label"],
[shell, "调度运营", "scheduler workbench tab label"],
[commandDeck, "打开策略中心", "home policy center entry"],
[commandDeck, "打开调度运营", "home scheduler workbench entry"],
[opsSettings, "策略中心", "settings policy center hint"],
[policyCenter, "批量拉取策略", "policy center batch pull UI"],
[policyCenter, "max_per_sweep", "policy center dispatch policy field"],
[policyCenter, "auto_dispatch_ready_steps", "policy center auto dispatch field"],
[policyCenter, "auto_reject_actions", "policy center risk reject field"],
[schedulerWorkbench, "立即检查调度", "scheduler immediate check UI"],
[schedulerWorkbench, "catch_up_on_startup", "scheduler catch up setting UI"],
[schedulerWorkbench, "max_run_history", "scheduler history setting UI"],
[docs, "派发策略管理 UI", "docs dispatch policy UI absorption"],
[docs, "多调度并发策略 UI", "docs scheduler policy UI absorption"],
[docs, "管理端正式路由 UI 预览", "docs route policy preview absorption"],
];
const missing = requiredSnippets
.filter(([content, snippet]) => !content.includes(snippet))
.map(([, , label]) => label);
if (missing.length > 0) {
throw new Error(`Missing policy center and scheduler workbench hooks: ${missing.join(", ")}`);
}
console.log("[DigitalEmployeeCheck] policy center and scheduler workbench hooks OK");
}
function checkLocalDatabaseSchema() {
console.log("\n[DigitalEmployeeCheck] Verify local SQLite digital schema");
const dbPath = path.join(os.homedir(), ".qimingclaw", "qimingclaw.db");
@@ -944,7 +1201,12 @@ function main() {
checkDigitalRouteDecisionPolicy();
checkDigitalDailyReportDelivery();
checkDigitalArtifactCleanup();
checkDigitalArtifactBatchGovernance();
checkDigitalBusinessViewUi();
checkDigitalInterventionWorkbench();
checkDigitalMemoryGovernanceAudit();
checkDigitalOpsNotificationReportWorkbenches();
checkDigitalPolicySchedulerWorkbenches();
checkLocalDatabaseSchema();
console.log("\n[DigitalEmployeeCheck] All checks passed");
} catch (error) {

View File

@@ -550,20 +550,20 @@ GET /api/digital-employee/approvals
1. **PlanStep 与依赖图(依赖图调度已开始)**
- 已吸收Plan / Task / Run 记录、计划模板、`create_plan` 中的 steps/tasks 可物化为任务PlanStep 已开始作为正式本地实体落入 `digital_plan_steps`
- 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox前置步骤跳过、取消或重试后会重新评估后继步骤推进为 ready / blocked / pending 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep派发前先通过管理端 `POST /api/digital-employee/leases/plan-step-dispatch/acquire` 做跨设备强租约仲裁,管理端拒绝会以 `remote_lease_rejected` 跳过,管理端异常会降级为 5 分钟本地软租约 `payload.dispatchLease.source = local_fallback` 并记录 `remote_error`;调用本地 `/computer/chat` 后会写入 `plan_step_dispatch_*``governance_start_run` 事件,派发完成或失败会先尝试 `release` 管理端租约再释放本地 lease 并写入 `plan_step_lease_*` 事件;已有未过期租约的步骤仍会以 `lease_active` 跳过auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy``/api/plan-step/dispatch-policy/pull`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。
- 后续缺口:管理端正式路由 UI、派发策略管理 UI 和跨端审批策略 UI 仍待吸收。
- 当前能力PlanStep 会进入 `digital_sync_outbox`entity type 为 `plan_step`;任务中心 flow 和 debug store 优先读取真实 PlanStep 生成步骤图Task Agent 的 retry / skip / cancel / pause / resume / input / run 控制已可更新真实 Task、PlanStep、Run、Event 和 outbox前置步骤跳过、取消或重试后会重新评估后继步骤推进为 ready / blocked / pending 并写入依赖图事件scheduler sweep 会识别可派发的 ready PlanStep派发前先通过管理端 `POST /api/digital-employee/leases/plan-step-dispatch/acquire` 做跨设备强租约仲裁,管理端拒绝会以 `remote_lease_rejected` 跳过,管理端异常会降级为 5 分钟本地软租约 `payload.dispatchLease.source = local_fallback` 并记录 `remote_error`;调用本地 `/computer/chat` 后会写入 `plan_step_dispatch_*``governance_start_run` 事件,派发完成或失败会先尝试 `release` 管理端租约再释放本地 lease 并写入 `plan_step_lease_*` 事件;已有未过期租约的步骤仍会以 `lease_active` 跳过auto sweep 会按节流从管理端 `GET /api/digital-employee/policies/plan-step-dispatch` 拉取远端派发策略,拉取失败时保留本地策略并记录 `last_pull_error`embedded 已提供 `/api/digital-employee/plan-steps``/api/digital-employee/plan-step-graph``/api/digital-employee/plans/:planId/dispatch-ready-steps``/api/plan-step/dispatch-policy``/api/plan-step/dispatch-policy/pull`ready PlanStep 安全派发、计划级跨任务推进、管理端依赖图视图、本地/远端派发策略、可观察租约状态和首页阻塞依赖处理入口已开始闭环。派发策略管理 UI 已开始吸收embedded 新增“策略中心”集中展示和编辑 `enabled/max_per_sweep/auto_dispatch_ready_steps`
- 后续缺口:正式外部管理端路由 UI、跨设备审批策略 UI 和跨设备策略冲突合并仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 或跨端审批策略 UI把本地可治理调度继续推进到跨端治理策略。
2. **调度 / 定时 / 周期任务**
- 已吸收:`digital_schedules``digital_schedule_runs` 已进入 qimingclaw 本地 SQLite 与 outboxentity type 为 `schedule` / `schedule_run`;旧 `/api/cron``/api/scheduler/jobs` 和治理命令会优先读取真实本地调度记录。
- 当前能力:主进程 `DigitalEmployeeScheduler` 可按本机 5 字段 cron 到点触发本地 `/computer/chat`,支持启动 catch-up、立即运行、暂停/恢复/删除、失败重试、运行历史和同步诊断。
- 后续缺口:更细的秒级 cron、复杂日历排除规则、多调度并发策略 UI 和管理端调度策略下发仍待吸收。
- 当前能力:主进程 `DigitalEmployeeScheduler` 可按本机 5 字段 cron 到点触发本地 `/computer/chat`,支持启动 catch-up、立即运行、暂停/恢复/删除、失败重试、运行历史和同步诊断。多调度并发策略 UI 已开始吸收embedded 新增“调度运营”入口,可查看 scheduler jobs、最近运行、`catch_up_on_startup``max_run_history`,并触发一次立即检查调度。
- 后续缺口:更细的秒级 cron、复杂日历排除规则、正式外部管理端调度策略下发和跨设备调度冲突合并仍待吸收。
- 建议:下一轮围绕入口路由/任务分流,把 schedule 触发的计划与具体 Skill Policy、Task Agent 状态机更精确地绑定。
3. **MemoryEntry / 长期记忆(已开始)**
- 已吸收qimingclaw `memoryService` 已投射为数字员工本地 `digital_memories`,支持从主客户端记忆列表同步、桥接新增和软删除;本地兜底记忆仍可在 memory 服务未初始化时工作。
- 当前能力embedded `/api/memory` 已由 qimingclaw adapter 接管GET / POST / DELETE 会通过 preload bridge 读写 `digital_memories`;记忆新增、更新和删除会进入 `digital_sync_outbox`entity type 为 `memory`,同步业务视图会提炼 key、category、source、status 和 content preview。
- 后续缺口:记忆合并、归档/恢复 UI、管理端长期记忆业务视图、记忆治理审计链和远端策略下发仍待吸收。
- 当前能力embedded `/api/memory` 已由 qimingclaw adapter 接管GET / POST / DELETE 会通过 preload bridge 读写 `digital_memories`;记忆新增、更新和删除会进入 `digital_sync_outbox`entity type 为 `memory`,同步业务视图会提炼 key、category、source、status 和 content preview。记忆归档/恢复 UI、管理端长期记忆业务视图和记忆治理审计链已开始吸收embedded 新增“记忆治理”入口,可从 runtime memories 派生 key/category/source/status/content preview、重复候选和同步状态并通过本地 overlay/audit 批量归档、恢复或标记复核。
- 后续缺口:真实合并执行、远端记忆策略下发和外部管理端长期记忆工作台仍待吸收。
- 建议:下一轮围绕管理端视图和记忆治理,把长期记忆从本地事实继续扩展到人工可审计、可归档、可合并的运营闭环。
4. **治理命令完整生命周期**
@@ -573,39 +573,39 @@ GET /api/digital-employee/approvals
- 建议:下一轮围绕正式管理端治理工作台和 ACP 硬中断,把本地治理审计推进到完整跨端裁决与执行控制。
5. **ManagedService 控制面**
- 已吸收Agent、MCP、Computer Server、Lanproxy、File Server、GUI Server 状态已进入 snapshot / projectionFile Server 与 GUI Server 已开放白名单式 restart bridge并会写入 `managed_service_restart` / `managed_service_restart_rejected` 事件,安全服务 restart bridge 已开始闭环。
- 缺口:仍缺少 start / stop、lease 持久化、重启历史、自动恢复策略和完整人机介入入口Agent、MCP、Computer Server、Lanproxy 等高风险服务不暴露数字员工重启动作。
- 已吸收Agent、MCP、Computer Server、Lanproxy、File Server、GUI Server 状态已进入 snapshot / projectionFile Server 与 GUI Server 已开放白名单式 restart bridge并会写入 `managed_service_restart` / `managed_service_restart_rejected` 事件,安全服务 restart bridge 已开始闭环。完整人机介入入口已开始吸收embedded 新增“处置台”聚合服务异常、诊断风险、审计风险和策略拉取错误,支持批量标记已看、忽略、策略重拉和白名单服务安全重启。
- 缺口:仍缺少 start / stop、lease 持久化、重启历史、自动恢复策略和正式外部管理端服务运营台Agent、MCP、Computer Server、Lanproxy 等高风险服务不暴露数字员工重启动作。
- 建议:先沉淀安全重启审计和人工确认体验,再评估更细粒度的服务恢复策略。
6. **技能生命周期与策略**
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1.local_skills`,管理端下发策略写入 `remote_skills`远端禁用优先生效且不会被本地按钮覆盖入口路由候选技能会按有效策略过滤MCP 注入 Agent 前会应用有效技能策略ACP permission / tool update 会写入 `skill_call_allowed` / `skill_call_rejected` / `skill_call_observed` 审计事件,`policy_snapshot` 会包含本地/远端策略命中字段embedded 已提供 `/api/digital-employee/skill-call-audits` 只读投影、`POST /api/skill/policies/pull` 手动拉取入口,技能调用策略与审计已开始闭环。
- 缺口:仍缺少技能安装、版本管理、细粒度权限范围和管理端调用审计视图
- 已吸收ACP / MCP / GUI / file server 能力已投射到 Skill Catalog技能库不再使用 demo fallbackSkill Library 启用/禁用已写入 qimingclaw 本地 `digital_employee_skill_policies_v1.local_skills`,管理端下发策略写入 `remote_skills`远端禁用优先生效且不会被本地按钮覆盖入口路由候选技能会按有效策略过滤MCP 注入 Agent 前会应用有效技能策略ACP permission / tool update 会写入 `skill_call_allowed` / `skill_call_rejected` / `skill_call_observed` 审计事件,`policy_snapshot` 会包含本地/远端策略命中字段embedded 已提供 `/api/digital-employee/skill-call-audits` 只读投影、`POST /api/skill/policies/pull` 手动拉取入口,技能调用策略与审计已开始闭环。管理端调用审计视图已开始吸收embedded 新增“技能审计”入口,将 skill call audit、tool_call_audit、sandbox_audit 和 token_cost 统一成可筛选列表,技能库可按 skill id 跳转聚焦。
- 缺口:仍缺少技能安装、版本管理、细粒度权限范围和正式外部管理端调用审计工作台
- 建议:下一轮围绕管理端审计消费、技能安装和权限范围,把本地调用审计推进到更完整的跨端治理闭环。
7. **产物交付闭环**
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件Artifact 版本/保留策略已开始闭环,业务产物视图可读取 version / retention 摘要,保留标记会写入 `artifact_retention_marked` / `artifact_retention_rejected` / `artifact_version_observed` 事件。真实文件清理执行已开始吸收,过期本地普通文件可通过独立 `cleanup` bridge 删除,清理结果会写入 `artifact_cleanup_executed` / `artifact_cleanup_rejected` / `artifact_cleanup_failed`artifact payload 与业务视图会投影 `cleanup_status``deleted_at` 和原因字段。
- 缺口:仍缺少保留策略批量治理和管理端跨设备聚合视图
- 建议:下一轮围绕管理端批量治理和跨设备版本聚合,把单产物清理能力推进到完整生命周期管理。
- 已吸收:运行 payload 中的 artifacts / outputs / files / uri 可提取为正式 `digital_artifacts` 并同步File Server workspace / upload / download / export / file update 生命周期状态已开始写入 Artifact payload 的 `delivery` 对象embedded `/api/artifact` 和日报产物来源可读取交付阶段、状态、文件与 workspace 信息Artifact 预览/下载权限已开始闭环,嵌入页通过受控 bridge 访问本地产物,并写入 `artifact_access_allowed` / `artifact_access_rejected` 审计事件Artifact 版本/保留策略已开始闭环,业务产物视图可读取 version / retention 摘要,保留标记会写入 `artifact_retention_marked` / `artifact_retention_rejected` / `artifact_version_observed` 事件。真实文件清理执行已开始吸收,过期本地普通文件可通过独立 `cleanup` bridge 删除,清理结果会写入 `artifact_cleanup_executed` / `artifact_cleanup_rejected` / `artifact_cleanup_failed`artifact payload 与业务视图会投影 `cleanup_status``deleted_at` 和原因字段。保留策略批量治理和跨设备聚合视图已开始吸收embedded 业务视图新增 `artifact-groups` 产物聚合入口,可按版本/来源聚合产物、统计设备/版本/保留/清理状态,并通过受控批量接口写入保留、到期或复核标记;日报产物来源也提供批量标记到期入口。
- 缺口:仍缺少批量真实文件清理、正式外部管理端生命周期工作台、复杂跨设备图谱和物化业务表
- 建议:下一轮围绕正式管理端生命周期工作台和跨设备图谱,把 embedded 预览聚合推进到跨端运营治理。
8. **审批 / 人工介入增强**
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影;风险审批策略已支持管理端远端下发、手动拉取、本地策略评估、高风险动作前置拦截和本地设置页管理,`/api/risk-approval/policy` 可读写本地策略并记录 `update_risk_approval_policy`;多步审批 workflow 已复用 `digital_approvals.payload`,支持步骤推进、转交/超时/风险动作更新当前步骤、管理端审批更新拉取和审批处理回写,审批动作历史、风险策略、多步流程与通知联动已开始闭环;跨端审批冲突可视化已开始吸收,管理端远端更新与本地终态/更新时间/current step 不一致时会保留本地状态、记录 `approval_conflict_detected`、在业务视图投影 `approval_conflict_*` 字段,并在首页审批卡暂停直接同意/驳回;冲突解决动作闭环已开始吸收,客户端可通过独立 `resolveApprovalConflict` bridge 选择保留本地、采纳管理端或标记已复核,裁决会写入 `approval_conflict_resolved` / `approval_conflict_resolution_rejected`,并在审批业务视图投影 `approval_conflict_resolution``approval_conflict_resolved_at` 和裁决人摘要。
- 缺口:仍缺少正式管理端审批工作台、跨设备批量冲突排查和管理端裁决策略 UI。
- 建议:下一轮围绕正式管理端审批工作台,把客户端单条冲突裁决推进到跨设备排查、批量处理和策略化回写。
- 已吸收approval 记录、workday decision、ACP permission 响应桥已有雏形;用户处理审批时会写入 `approval_decision` runtime event评论、转交、超时、风险标记会写入 `approval_action_recorded` / `approval_action_rejected`,并联动 embedded `/api/approval/:approvalId/timeline`、审批列表摘要、管理端 `/api/digital-employee/approval-history` 历史视图、`/api/approval/subscriptions` 订阅策略和 notification 投影;风险审批策略已支持管理端远端下发、手动拉取、本地策略评估、高风险动作前置拦截和本地设置页管理,`/api/risk-approval/policy` 可读写本地策略并记录 `update_risk_approval_policy`;多步审批 workflow 已复用 `digital_approvals.payload`,支持步骤推进、转交/超时/风险动作更新当前步骤、管理端审批更新拉取和审批处理回写,审批动作历史、风险策略、多步流程与通知联动已开始闭环;跨端审批冲突可视化已开始吸收,管理端远端更新与本地终态/更新时间/current step 不一致时会保留本地状态、记录 `approval_conflict_detected`、在业务视图投影 `approval_conflict_*` 字段,并在首页审批卡暂停直接同意/驳回;冲突解决动作闭环已开始吸收,客户端可通过独立 `resolveApprovalConflict` bridge 选择保留本地、采纳管理端或标记已复核,裁决会写入 `approval_conflict_resolved` / `approval_conflict_resolution_rejected`,并在审批业务视图投影 `approval_conflict_resolution``approval_conflict_resolved_at` 和裁决人摘要。跨设备批量冲突排查已开始吸收embedded 新增“介入台”聚合审批冲突、风险审批、路由干预和待处理通知,支持筛选、分页、批量保留本地和批量标记复核;批量采纳管理端被 `batch_accept_remote_disabled` 保护,避免扩大状态分叉。
- 缺口:仍缺少正式管理端审批工作台和管理端裁决策略 UI。
- 建议:下一轮围绕正式管理端审批工作台,把 embedded 介入台预览推进到外部管理端批量排查和策略化回写。
9. **通知 / Inbox / 用户消息**
- 已吸收运行事件和同步失败能在数字员工页面展示embedded adapter 已接管 sgRobot 风格 `/api/notifications`、未读数、read/dismiss/reply本地 UI state 会保存通知已读、忽略和回复 overlay顶部 Bell 已开始展示 approval、同步失败、服务异常和任务完成通知本地通知订阅偏好已接入 `/api/notifications/preferences`,可按总开关、桌面通知开关、等级和常用类型过滤 Inbox`/api/digital-employee/notifications` 已提供管理端可消费的本地通知业务视图,通知动作和订阅偏好事件的 sync `business_view` 会归类为 notification 并提炼状态字段;`need_input` 通知首次回复会同步转化为 `provide_task_input` 治理命令,保留通知回复 overlay 的同时写入 Task/Run/Event/outbox客户端已支持管理端通知状态拉取与本地通知动作回写未读 action/warn/error 通知会按偏好触发一次系统级桌面提醒,系统权限引导 UI 已开始吸收Bell 设置面板可展示桌面通知可用性、测试发送结果、自动通知失败原因,并通过系统设置入口辅助修复,`digital_workday_notification_*` 同步事件只暴露通知 ID、状态、endpoint、错误和回复长度通知投影、本地订阅策略、桌面提醒、系统权限引导、跨端未读状态与任务输入链路已开始闭环。
- 缺口:仍缺少真实管理端通知页面。
- 缺口:真实管理端通知页面预览已开始吸收embedded 新增“通知运营”入口,可筛选全部/未读/待处理/异常通知,支持批量已读、批量忽略、拉取远端通知、打开系统通知设置和单条回复;仍缺少正式外部管理端通知页面。
- 建议:下一轮围绕正式管理端通知工作台,把 Inbox 从嵌入页投影推进到完整跨端协作入口。
10. **入口路由 / 任务分流(路由业务视图与通知联动已开始)**
- 已吸收:管理端通过 `/computer/chat` 进入 qimingclaw 后会生成本地任务事实embedded `/api/ingress/route``/api/route-decisions``/api/digital-employee/route-decisions` 已由 qimingclaw adapter 接管。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decision自动映射前会执行低风险/受保护高风险 action guard未允许的控制动作会记录 `route_action_policy_blocked``route_action_missing_context`,避免静默创建命令;入口路由策略可从管理端远端下发,支持按 intent 阻断、按 intent 要求审批、限制自动创建 governance command并在策略干预时先记录 route decision 再停止本地命令执行;首页已展示路由干预队列和治理审计摘要,审批通过/拒绝会写入本地 approval decision 并回写管理端,管理端审批更新拉取后会自动扫描已批准 route approval 并按幂等规则恢复执行scheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要、治理审计、策略来源/拉取错误、阻断/待审/待干预/已恢复计数和通知联动已开始闭环。
- 后续缺口:管理端正式路由 UI、跨设备路由干预冲突可视化和批量干预入口仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把入口路由从客户端可运营干预推进到管理端运营台。
- 当前能力入口请求会按上下文、ready PlanStep、调度、查询和控制动作生成 route decision并写入 `digital_events.kind = route_decision` 与现有 event outbox路由时间线优先从 qimingclaw 本地事件读取;低风险 route decision 已开始映射为明确的本地 governance command并把 `created_command_id` 回填到 route decision自动映射前会执行低风险/受保护高风险 action guard未允许的控制动作会记录 `route_action_policy_blocked``route_action_missing_context`,避免静默创建命令;入口路由策略可从管理端远端下发,支持按 intent 阻断、按 intent 要求审批、限制自动创建 governance command并在策略干预时先记录 route decision 再停止本地命令执行;首页已展示路由干预队列和治理审计摘要,审批通过/拒绝会写入本地 approval decision 并回写管理端,管理端审批更新拉取后会自动扫描已批准 route approval 并按幂等规则恢复执行scheduler check 已接入 ready PlanStep 安全派发,首页立即执行后会触发一次派发 sweep 并展示派发结果;路由决策本地业务视图、首页摘要、治理审计、策略来源/拉取错误、阻断/待审/待干预/已恢复计数和通知联动已开始闭环。管理端正式路由 UI 预览已开始吸收,“策略中心”会把入口路由策略纳入统一拉取与状态展示。
- 后续缺口:正式外部管理端路由 UI、路由策略编辑和跨设备路由干预冲突可视化仍待吸收。
- 建议:下一轮围绕管理端正式路由 UI 和跨设备冲突可视化,把 embedded 批量干预入口推进到管理端运营台。
11. **诊断 / 成本 / 审计视图**
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成诊断、估算成本和审计投影sandbox audit、token/cost 和 CLI/tool 调用事件已开始通过 `digital_events` 与 outbox `business_view` 提炼为 `sandbox_audit``token_cost``tool_call_audit` 分类,并只同步 session、tool、server、operation、status、exit code、duration、model、token 计数和估算成本等安全字段;数字员工首页已展示诊断/审计/Token/估算成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。
- 缺口:真实计费账单、独立 sandbox audit 文件归档、诊断修复动作和管理端正式审计视图尚未统一吸收。
- 已吸收:同步失败诊断较完整,部分 sandbox / memory / service 日志在 qimingclaw 其他模块中存在embedded adapter 已接管 `/api/doctor``/api/cost``/api/audit`,从 snapshot、runtime records、sync status、managed services 和 artifact delivery facts 生成诊断、估算成本和审计投影sandbox audit、token/cost 和 CLI/tool 调用事件已开始通过 `digital_events` 与 outbox `business_view` 提炼为 `sandbox_audit``token_cost``tool_call_audit` 分类,并只同步 session、tool、server、operation、status、exit code、duration、model、token 计数和估算成本等安全字段;数字员工首页已展示诊断/审计/Token/估算成本摘要,`/api/digital-employee/audits` 已提供本地审计业务视图,诊断与审计风险通知联动已开始闭环。管理端正式审计视图预览已开始吸收embedded “技能审计”会消费 sandbox/tool/cost 风险来源并保留安全 payload preview。诊断修复动作预览已开始吸收“处置台”可把 doctor/audit/service/policy 风险转为可筛选、可标记和可安全重试的运营事项。
- 缺口:真实计费账单、独立 sandbox audit 文件归档、自动诊断修复动作和正式外部管理端审计视图尚未统一吸收。
- 建议:下一轮围绕管理端正式审计视图和诊断修复动作,把本地可追溯审计继续推进到可运营处置。
12. **管理端业务查询模型**
@@ -614,8 +614,8 @@ GET /api/digital-employee/approvals
- 建议:先使用 sync record 派生业务查询模型和 embedded 业务视图预览承接联调,再按容量和查询压力决定是否拆 Plan / Task / Run / Event 物理业务表。
13. **日报实体与导出交付**
- 已吸收embedded 页面可基于当前 workday projection 导出本地文本日报;生成日报时会通过 qimingclaw bridge 写入 `daily_report` text / HTML / PDF Artifact 与 `daily_report_generated` Event并可通过 `/api/digital-employee/daily-reports` 和详情接口读取只读日报投影。日报 HTML/PDF 导出、发送记录、管理端确认回执和日报发送审批已开始吸收,交付动作会写入 `daily_report_send_recorded` / `daily_report_confirmed` / `daily_report_approval_requested` 事件sync `business_view.category = daily_report` 可被管理端筛选消费。
- 缺口:没有正式 DailyReport 物理表、真实管理端发送通道、管理端正式日报页面和审批裁决回写闭环。
- 已吸收embedded 页面可基于当前 workday projection 导出本地文本日报;生成日报时会通过 qimingclaw bridge 写入 `daily_report` text / HTML / PDF Artifact 与 `daily_report_generated` Event并可通过 `/api/digital-employee/daily-reports` 和详情接口读取只读日报投影。日报 HTML/PDF 导出、发送记录、管理端确认回执和日报发送审批已开始吸收,交付动作会写入 `daily_report_send_recorded` / `daily_report_confirmed` / `daily_report_approval_requested` 事件sync `business_view.category = daily_report` 可被管理端筛选消费。管理端正式日报页面预览已开始吸收embedded 新增“日报运营”入口,展示日报生成、交付、确认、审批和关联产物状态,并复用现有生成、记录发送、确认回执和请求审批动作。
- 缺口:没有正式 DailyReport 物理表、真实管理端发送通道、正式外部管理端日报页面和审批裁决回写闭环。
- 建议:先稳定 Artifact/Event 派生日报交付模型,再根据管理端联调需要决定是否物化 DailyReport 表与发送记录表。
## 管理端联动原则