吸收数字员工运营预览台

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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