吸收数字员工运营预览台
This commit is contained in:
@@ -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',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 与 outbox,entity 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 / projection;File 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 / projection;File 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 fallback;Skill 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 fallback;Skill 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 表与发送记录表。
|
||||
|
||||
## 管理端联动原则
|
||||
|
||||
Reference in New Issue
Block a user