吸收数字员工运营预览台
This commit is contained in:
@@ -17,6 +17,17 @@ import type {
|
||||
DigitalEmployeeBusinessViewKind,
|
||||
DigitalEmployeeBusinessViewQuery,
|
||||
DigitalEmployeeBusinessViewRow,
|
||||
DigitalEmployeeBatchActionResult,
|
||||
DigitalEmployeeInterventionWorkbenchResponse,
|
||||
DigitalEmployeeMemoryGovernanceResponse,
|
||||
DigitalEmployeeSkillAuditWorkbenchResponse,
|
||||
DigitalEmployeeWorkbenchBatchResult,
|
||||
DigitalEmployeeOpsWorkbenchResponse,
|
||||
DigitalEmployeeNotificationWorkbenchResponse,
|
||||
DigitalEmployeeDailyReportWorkbenchResponse,
|
||||
DigitalEmployeePolicyCenterResponse,
|
||||
DigitalEmployeePolicyPullBatchResult,
|
||||
DigitalEmployeeSchedulerWorkbenchResponse,
|
||||
PlanStepBusinessRow,
|
||||
PlanStepDispatchPolicy,
|
||||
PlanStepDispatchPolicyPullResult,
|
||||
@@ -295,7 +306,7 @@ export function deleteCronJob(id: string): Promise<void> {
|
||||
}
|
||||
export function patchCronJob(
|
||||
id: string,
|
||||
patch: { name?: string; schedule?: string; command?: string },
|
||||
patch: { name?: string; schedule?: string; command?: string; enabled?: boolean },
|
||||
): Promise<CronJob> {
|
||||
return apiFetch<CronJob | { status: string; job: CronJob }>(
|
||||
`/api/cron/${encodeURIComponent(id)}`,
|
||||
@@ -336,6 +347,40 @@ export function patchCronSettings(
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDigitalEmployeeSchedulerWorkbench(): Promise<DigitalEmployeeSchedulerWorkbenchResponse> {
|
||||
const [jobs, settings] = await Promise.all([
|
||||
getSchedulerJobs(),
|
||||
getCronSettings().catch(() => ({ enabled: false, catch_up_on_startup: false, max_run_history: 20 })),
|
||||
]);
|
||||
const runEntries = await Promise.all(jobs.slice(0, 30).map(async (job) => {
|
||||
const runs = await getCronRuns(job.id, 5).catch(() => []);
|
||||
return [job.id, runs] as const;
|
||||
}));
|
||||
const runsByJob = new Map(runEntries);
|
||||
return {
|
||||
items: jobs.map((job) => {
|
||||
const runs = runsByJob.get(job.id) ?? [];
|
||||
const latest = runs[0];
|
||||
const schedule = job.schedule && typeof job.schedule === 'object' ? job.schedule as Record<string, unknown> : {};
|
||||
return {
|
||||
schedule_id: job.id,
|
||||
name: job.name || job.id,
|
||||
enabled: job.enabled !== false,
|
||||
status: job.enabled === false ? 'paused' : String(job.last_status || schedule.status || 'enabled'),
|
||||
cron_expr: job.expression || String(schedule.expression || ''),
|
||||
next_run: job.next_run || null,
|
||||
last_run: job.last_run || latest?.finished_at || latest?.started_at || null,
|
||||
last_status: job.last_status || latest?.status || null,
|
||||
latest_run_error: job.last_output || latest?.output || null,
|
||||
run_count: runs.length,
|
||||
payload: { job, runs },
|
||||
};
|
||||
}),
|
||||
settings,
|
||||
source: 'qimingclaw-adapter',
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integrations
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -521,6 +566,117 @@ export function decideRouteIntervention(
|
||||
});
|
||||
}
|
||||
|
||||
export function decideRouteInterventionsBatch(
|
||||
routeDecisionIds: string[],
|
||||
input: { decision: 'mark_reviewed' | 'dismiss' | 'retry_policy_check'; actor?: string | null; comment?: string | null },
|
||||
): Promise<DigitalEmployeeBatchActionResult> {
|
||||
return apiFetch<DigitalEmployeeBatchActionResult>('/api/route-decisions/interventions/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ route_decision_ids: routeDecisionIds, ...input }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeInterventionWorkbench(
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<DigitalEmployeeInterventionWorkbenchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<DigitalEmployeeInterventionWorkbenchResponse>(`/api/digital-employee/interventions/workbench${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeMemoryGovernance(
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<DigitalEmployeeMemoryGovernanceResponse> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<DigitalEmployeeMemoryGovernanceResponse>(`/api/digital-employee/memory-governance${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function setMemoryGovernanceStatus(
|
||||
memoryIds: string[],
|
||||
action: 'archive' | 'restore' | 'mark_reviewed',
|
||||
options: { actor?: string | null; reason?: string | null } = {},
|
||||
): Promise<DigitalEmployeeBatchActionResult> {
|
||||
return apiFetch<DigitalEmployeeBatchActionResult>('/api/digital-employee/memory-governance/status/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ memory_ids: memoryIds, action, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeSkillAuditWorkbench(
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<DigitalEmployeeSkillAuditWorkbenchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<DigitalEmployeeSkillAuditWorkbenchResponse>(`/api/digital-employee/skill-audit-workbench${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeOpsWorkbench(
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<DigitalEmployeeOpsWorkbenchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<DigitalEmployeeOpsWorkbenchResponse>(`/api/digital-employee/ops-workbench${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function runDigitalEmployeeOpsBatchAction(
|
||||
opsIds: string[],
|
||||
action: 'mark_reviewed' | 'dismiss' | 'open_related_view' | 'retry_policy_pull' | 'safe_restart_service',
|
||||
options: { actor?: string | null; reason?: string | null } = {},
|
||||
): Promise<DigitalEmployeeWorkbenchBatchResult> {
|
||||
return apiFetch<DigitalEmployeeWorkbenchBatchResult>('/api/digital-employee/ops-workbench/actions/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ ops_ids: opsIds, action, ...options }),
|
||||
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeNotificationWorkbench(
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<DigitalEmployeeNotificationWorkbenchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<DigitalEmployeeNotificationWorkbenchResponse>(`/api/digital-employee/notifications/workbench${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function runDigitalEmployeeNotificationBatchAction(
|
||||
notificationIds: string[],
|
||||
action: 'mark_read' | 'dismiss' | 'pull_updates' | 'open_desktop_settings',
|
||||
options: { actor?: string | null; reason?: string | null } = {},
|
||||
): Promise<DigitalEmployeeWorkbenchBatchResult> {
|
||||
return apiFetch<DigitalEmployeeWorkbenchBatchResult>('/api/digital-employee/notifications/actions/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ notification_ids: notificationIds, action, ...options }),
|
||||
timeoutMs: DIGITAL_EMPLOYEE_WORKDAY_TIMEOUT_MS,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDigitalEmployeeDailyReportWorkbench(
|
||||
params: Record<string, string | number | null | undefined> = {},
|
||||
): Promise<DigitalEmployeeDailyReportWorkbenchResponse> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && `${value}`.trim() !== '') query.set(key, `${value}`);
|
||||
});
|
||||
const suffix = query.toString();
|
||||
return apiFetch<DigitalEmployeeDailyReportWorkbenchResponse>(`/api/digital-employee/daily-reports/workbench${suffix ? `?${suffix}` : ''}`);
|
||||
}
|
||||
|
||||
export function routeIngress(
|
||||
body: IngressRouteRequest,
|
||||
): Promise<IngressRouteResponse> {
|
||||
@@ -698,6 +854,77 @@ export function pullRouteDecisionPolicy(): Promise<RouteDecisionPolicyPullResult
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDigitalEmployeePolicyCenter(): Promise<DigitalEmployeePolicyCenterResponse> {
|
||||
const [dispatchPolicy, riskPolicy] = await Promise.all([
|
||||
getPlanStepDispatchPolicy(),
|
||||
getRiskApprovalPolicy(),
|
||||
]);
|
||||
return {
|
||||
plan_step_dispatch_policy: dispatchPolicy,
|
||||
risk_approval_policy: riskPolicy,
|
||||
source: 'qimingclaw-adapter',
|
||||
items: [
|
||||
{
|
||||
policy_id: 'plan_step_dispatch',
|
||||
title: 'PlanStep 派发策略',
|
||||
status: dispatchPolicy.enabled ? 'enabled' : 'disabled',
|
||||
source: dispatchPolicy.source,
|
||||
updated_at: dispatchPolicy.updated_at,
|
||||
last_pulled_at: dispatchPolicy.last_pulled_at,
|
||||
last_pull_error: dispatchPolicy.last_pull_error,
|
||||
summary: `max_per_sweep=${dispatchPolicy.max_per_sweep}; auto_dispatch_ready_steps=${dispatchPolicy.auto_dispatch_ready_steps}`,
|
||||
payload: dispatchPolicy,
|
||||
},
|
||||
{
|
||||
policy_id: 'risk_approval',
|
||||
title: '风险审批策略',
|
||||
status: riskPolicy.enabled ? 'enabled' : 'disabled',
|
||||
source: riskPolicy.source,
|
||||
updated_at: riskPolicy.updated_at,
|
||||
last_pulled_at: riskPolicy.last_pulled_at,
|
||||
last_pull_error: riskPolicy.last_pull_error,
|
||||
summary: `approval_required=${riskPolicy.approval_required_risk_levels.join(',')}; auto_reject_actions=${riskPolicy.auto_reject_actions.length}`,
|
||||
payload: riskPolicy,
|
||||
},
|
||||
{
|
||||
policy_id: 'route_decision',
|
||||
title: '入口路由策略',
|
||||
status: 'pull_available',
|
||||
source: 'remote_pull',
|
||||
summary: '通过 /api/route-decision/policy/pull 获取管理端路由策略快照。',
|
||||
},
|
||||
{
|
||||
policy_id: 'skill',
|
||||
title: '技能策略',
|
||||
status: 'pull_available',
|
||||
source: 'remote_pull',
|
||||
summary: '通过 /api/skill/policies/pull 同步远端技能启停策略。',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export async function pullDigitalEmployeePoliciesBatch(): Promise<DigitalEmployeePolicyPullBatchResult> {
|
||||
const jobs: Array<{ policy_id: string; run: () => Promise<unknown> }> = [
|
||||
{ policy_id: 'plan_step_dispatch', run: pullPlanStepDispatchPolicy },
|
||||
{ policy_id: 'risk_approval', run: pullRiskApprovalPolicy },
|
||||
{ policy_id: 'route_decision', run: pullRouteDecisionPolicy },
|
||||
{ policy_id: 'skill', run: pullSkillPolicies },
|
||||
];
|
||||
const items = [] as DigitalEmployeePolicyPullBatchResult['items'];
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
const result = await job.run();
|
||||
const record = result as { ok?: boolean; error?: string | null };
|
||||
items.push({ policy_id: job.policy_id, ok: record.ok !== false, error: record.error ?? null, result });
|
||||
} catch (error) {
|
||||
items.push({ policy_id: job.policy_id, ok: false, error: error instanceof Error ? error.message : String(error) });
|
||||
}
|
||||
}
|
||||
const pulled = items.filter((item) => item.ok).length;
|
||||
return { ok: pulled > 0, total: items.length, pulled, failed: items.length - pulled, items };
|
||||
}
|
||||
|
||||
export function getPlanSteps(params: Record<string, string | number | null | undefined> = {}): Promise<BusinessViewResponse<PlanStepBusinessRow>> {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
@@ -726,6 +953,7 @@ export function getDigitalEmployeeBusinessView(
|
||||
runs: '/api/digital-employee/runs',
|
||||
events: '/api/digital-employee/events',
|
||||
artifacts: '/api/digital-employee/artifacts',
|
||||
'artifact-groups': '/api/digital-employee/artifact-groups',
|
||||
approvals: '/api/digital-employee/approvals',
|
||||
};
|
||||
const query = new URLSearchParams();
|
||||
@@ -961,6 +1189,17 @@ export function setArtifactRetention(
|
||||
});
|
||||
}
|
||||
|
||||
export function setArtifactRetentionBatch(
|
||||
artifactIds: string[],
|
||||
action: 'mark_keep' | 'mark_expire' | 'mark_review' | 'clear_retention',
|
||||
options: Record<string, string | null | undefined> = {},
|
||||
): Promise<any> {
|
||||
return apiFetch('/api/digital-employee/artifacts/retention/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ artifact_ids: artifactIds, action, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
export function cleanupArtifact(
|
||||
artifactId: string,
|
||||
options: Record<string, string | null | undefined> = {},
|
||||
@@ -992,6 +1231,17 @@ export function resolveApprovalConflict(
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveApprovalConflictsBatch(
|
||||
approvalIds: string[],
|
||||
resolution: 'keep_local' | 'mark_reviewed',
|
||||
options: { actor?: string | null; comment?: string | null } = {},
|
||||
): Promise<DigitalEmployeeBatchActionResult> {
|
||||
return apiFetch<DigitalEmployeeBatchActionResult>('/api/approval/conflicts/resolve/batch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ approval_ids: approvalIds, resolution, ...options }),
|
||||
});
|
||||
}
|
||||
|
||||
export function pullApprovalUpdates(): Promise<{ ok: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }> {
|
||||
return apiFetch<{ ok: boolean; applied?: number; ignored?: number; conflicted?: number; skipped?: boolean; error?: string | null }>('/api/approval/updates/pull', {
|
||||
method: 'POST',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { Eye, RefreshCw, Search, X } from 'lucide-react';
|
||||
import { CheckSquare, Eye, RefreshCw, Search, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeBusinessView } from '@/lib/api';
|
||||
import { getDigitalEmployeeBusinessView, setArtifactRetentionBatch } from '@/lib/api';
|
||||
import type {
|
||||
DigitalEmployeeBusinessViewKind,
|
||||
DigitalEmployeeBusinessViewQuery,
|
||||
@@ -15,6 +15,7 @@ const VIEW_OPTIONS: Array<{ id: DigitalEmployeeBusinessViewKind; label: string }
|
||||
{ id: 'runs', label: '运行' },
|
||||
{ id: 'events', label: '事件' },
|
||||
{ id: 'artifacts', label: '产物' },
|
||||
{ id: 'artifact-groups', label: '产物聚合' },
|
||||
{ id: 'approvals', label: '审批' },
|
||||
];
|
||||
|
||||
@@ -106,10 +107,16 @@ function kindExtra(row: DigitalEmployeeBusinessViewRow, kind: DigitalEmployeeBus
|
||||
if (kind === 'runs') return displayText(row.last_event_message || row.duration_ms, '无最近事件');
|
||||
if (kind === 'events') return displayText(row.kind, 'event');
|
||||
if (kind === 'artifacts') return displayText(row.cleanup_status || row.deleted_at || row.uri || row.kind, 'artifact');
|
||||
if (kind === 'artifact-groups') return `产物 ${displayText(row.artifact_count, '0')} / 设备 ${displayText(row.device_count, '0')} / 版本 ${displayText(row.version_count, '0')}`;
|
||||
if (kind === 'approvals') return displayText(row.approval_conflict_resolution || row.approval_conflict_resolved_at || row.decision || row.approval_conflict_reason || row.kind, 'approval');
|
||||
return displayText(row.assigned_skill_id || row.operation || row.kind);
|
||||
}
|
||||
|
||||
function rowArtifactIds(row: DigitalEmployeeBusinessViewRow): string[] {
|
||||
if (Array.isArray(row.artifact_ids)) return row.artifact_ids.map(String).filter(Boolean);
|
||||
return row.artifact_id ? [String(row.artifact_id)] : [];
|
||||
}
|
||||
|
||||
function queryFromFilters(filters: FilterState, pageNo: number): DigitalEmployeeBusinessViewQuery {
|
||||
return {
|
||||
entity_id: filters.entity.trim() || null,
|
||||
@@ -129,6 +136,13 @@ function statusClass(value: unknown): string {
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
function relatedWorkbenchTarget(row: DigitalEmployeeBusinessViewRow): { label: string; search: string } | null {
|
||||
if (row.notification_id || row.entity_type === 'notification') return { label: '打开通知运营', search: `?tab=notification-workbench&entity=${encodeURIComponent(rowEntityId(row))}` };
|
||||
if (row.report_id || row.entity_type === 'daily_report') return { label: '打开日报运营', search: `?tab=daily-report-workbench&entity=${encodeURIComponent(rowEntityId(row))}` };
|
||||
if (row.service_id || row.audit_id || row.entity_type === 'managed_service' || row.entity_type === 'audit') return { label: '打开处置台', search: `?tab=ops-workbench&entity=${encodeURIComponent(rowEntityId(row))}` };
|
||||
return null;
|
||||
}
|
||||
|
||||
function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | null; onClose: () => void }) {
|
||||
if (!row) {
|
||||
return <aside style={detailStyle}><div style={emptyDetailStyle}>选择一行业务对象查看详情。</div></aside>;
|
||||
@@ -137,6 +151,7 @@ function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | n
|
||||
business_view: row.business_view ?? null,
|
||||
payload: row.payload ?? null,
|
||||
} : null;
|
||||
const relatedTarget = relatedWorkbenchTarget(row);
|
||||
return (
|
||||
<aside style={detailStyle} aria-label="业务视图详情">
|
||||
<div className="de-artifact-preview">
|
||||
@@ -155,6 +170,7 @@ function DetailPanel({ row, onClose }: { row: DigitalEmployeeBusinessViewRow | n
|
||||
row.sync_record_url ? `Sync Record:${row.sync_record_url}` : '',
|
||||
row.summary ? `摘要:${row.summary}` : '',
|
||||
].filter(Boolean).join('\n')}</pre>
|
||||
{relatedTarget ? <button type="button" className="de-btn-secondary de-btn-sm" onClick={() => { window.location.search = relatedTarget.search; }}>{relatedTarget.label}</button> : null}
|
||||
</div>
|
||||
{jsonPayload ? (
|
||||
<details className="de-business-json" open>
|
||||
@@ -174,8 +190,10 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<DigitalEmployeeBusinessViewRow | null>(null);
|
||||
const [selectedArtifactIds, setSelectedArtifactIds] = useState<string[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setKind(normalizeKind(initialKind));
|
||||
@@ -193,6 +211,7 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
setSelectedArtifactIds((current) => current.filter((artifactId) => (data.items ?? []).some((item) => rowArtifactIds(item).includes(artifactId))));
|
||||
setSelected((current) => {
|
||||
if (!current) return null;
|
||||
return (data.items ?? []).find((item) => rowEntityId(item) === rowEntityId(current)) ?? null;
|
||||
@@ -211,12 +230,44 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
|
||||
}, [loadRows]);
|
||||
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
const supportsArtifactBatch = kind === 'artifacts' || kind === 'artifact-groups';
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const toggleArtifactSelection = (row: DigitalEmployeeBusinessViewRow) => {
|
||||
const ids = rowArtifactIds(row);
|
||||
setSelectedArtifactIds((current) => {
|
||||
const selectedAll = ids.length > 0 && ids.every((id) => current.includes(id));
|
||||
return selectedAll
|
||||
? current.filter((id) => !ids.includes(id))
|
||||
: Array.from(new Set([...current, ...ids]));
|
||||
});
|
||||
};
|
||||
|
||||
const runArtifactBatch = async (action: 'mark_keep' | 'mark_expire' | 'mark_review') => {
|
||||
if (selectedArtifactIds.length === 0 || loading) return;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await setArtifactRetentionBatch(selectedArtifactIds, action, { reason: 'digital_employee_artifact_batch_governance' });
|
||||
if (!result?.ok) {
|
||||
setNotice(result?.error || '批量保留策略写入失败');
|
||||
return;
|
||||
}
|
||||
setNotice(`批量保留策略已记录:${result.recorded ?? 0}/${result.total ?? selectedArtifactIds.length}`);
|
||||
setSelectedArtifactIds([]);
|
||||
await loadRows();
|
||||
} catch (batchError) {
|
||||
setError(batchError instanceof Error ? batchError.message : '批量保留策略写入失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
@@ -264,6 +315,17 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{supportsArtifactBatch ? (
|
||||
<div className="de-business-actions" aria-label="批量保留治理">
|
||||
<span className="de-card-meta-text">已选择 {selectedArtifactIds.length} 个产物</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => { void runArtifactBatch('mark_keep'); }}><CheckSquare size={14} /> 批量保留</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => { void runArtifactBatch('mark_expire'); }}>批量标记到期</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => { void runArtifactBatch('mark_review'); }}>批量复核</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedArtifactIds.length === 0 || loading} onClick={() => setSelectedArtifactIds([])}>清空选择</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
|
||||
<div style={contentStyle} className="de-business-layout">
|
||||
@@ -271,6 +333,7 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
|
||||
<table style={tableStyle} className="de-business-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{supportsArtifactBatch ? <th>选择</th> : null}
|
||||
<th>标题 / 实体</th>
|
||||
<th>状态</th>
|
||||
<th>关联</th>
|
||||
@@ -282,9 +345,12 @@ export default function BusinessView({ initialKind, focusEntityId }: { initialKi
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={7}>{loading ? '加载中...' : '暂无业务对象'}</td></tr>
|
||||
<tr><td colSpan={supportsArtifactBatch ? 8 : 7}>{loading ? '加载中...' : '暂无业务对象'}</td></tr>
|
||||
) : rows.map((row) => (
|
||||
<tr key={`${kind}:${rowEntityId(row)}:${rowTime(row)}`} className={selected && rowEntityId(selected) === rowEntityId(row) ? 'active' : ''}>
|
||||
{supportsArtifactBatch ? (
|
||||
<td><input type="checkbox" checked={rowArtifactIds(row).length > 0 && rowArtifactIds(row).every((id) => selectedArtifactIds.includes(id))} onChange={() => toggleArtifactSelection(row)} aria-label="选择产物" /></td>
|
||||
) : null}
|
||||
<td><strong>{rowTitle(row)}</strong><code>{rowEntityId(row)}</code></td>
|
||||
<td><span className={statusClass(row.status)}>{displayText(row.status)}</span></td>
|
||||
<td><span>{displayText(row.plan_id)}</span><span>{displayText(row.task_id)}</span><span>{displayText(row.run_id)}</span></td>
|
||||
|
||||
@@ -2518,6 +2518,8 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
{routeSummary.latest_intervention_status ? ` · 干预${routeSummary.latest_intervention_status}` : ''}
|
||||
{routeSummary.policy_last_pull_error ? ` · 拉取失败` : ''}
|
||||
</small>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'interventions', interventionKind: 'route_intervention' })}>打开介入台</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'policy-center' })}>打开策略中心</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -2529,6 +2531,10 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<span><em>Token</em><strong>{formatCompactNumber(diagnosticSummary.cost.total_tokens)}</strong></span>
|
||||
<span><em>估算成本</em><strong>{formatEstimatedCostUsd(diagnosticSummary.cost.estimated_cost_usd)}</strong></span>
|
||||
<small>{latestDiagnosticIssue ? `${latestDiagnosticIssue.category} · ${latestDiagnosticIssue.message}` : '诊断、审计和成本投影正常'}</small>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'ops-workbench', opsKind: latestDiagnosticIssue?.source === 'audit' ? 'audit' : 'diagnostic' })}>打开处置台</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'scheduler-workbench' })}>打开调度运营</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'notification-workbench', notificationKind: 'all' })}>打开通知运营</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'skill-audit' })}>打开技能审计</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -2801,6 +2807,13 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<Download size={14} aria-hidden="true" />
|
||||
下载日报
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="de-btn-secondary de-btn-sm"
|
||||
onClick={() => onNavigate({ tab: 'daily-report-workbench', entityId: dailyReport?.report_id })}
|
||||
>
|
||||
打开日报运营
|
||||
</button>
|
||||
</div>
|
||||
<div className="de-report-meta">
|
||||
<p>{dailyReport?.summary ?? '点击生成日报后,AI 会基于当天所有任务写出总分结构日报。'}</p>
|
||||
@@ -3560,6 +3573,7 @@ export default function CommandDeck({ onNavigate }: { onNavigate: (target: Digit
|
||||
<div className="de-decision-chain-note">
|
||||
<span className={`de-badge ${approvalConflict ? 'de-badge-danger' : 'de-badge-success'}`}>{approvalConflict ? '冲突保护' : '本地记录'}</span>
|
||||
<span>{approvalConflict ? '直接同意/驳回已暂停,请先同步或在管理端工作台排查。' : isDecisionOpen(decision.status) ? '处理结果会写入 qimingclaw runtime event。' : '该事项已在本地状态中处理。'}</span>
|
||||
{approvalConflict ? <button type="button" className="de-btn-secondary de-btn-sm" onClick={() => onNavigate({ tab: 'interventions', interventionKind: 'approval_conflict', entityId: decision.id })}>打开介入台</button> : null}
|
||||
</div>
|
||||
<div className="de-workbench-actions">
|
||||
{decision.actions.map((action) => (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getArtifactVersions,
|
||||
recordDailyReportDelivery,
|
||||
setArtifactRetention,
|
||||
setArtifactRetentionBatch,
|
||||
} from '@/lib/api';
|
||||
import type {
|
||||
DigitalEmployeeDailyReport,
|
||||
@@ -323,8 +324,38 @@ function ArtifactSourceList({ artifacts }: { artifacts: DigitalEmployeeReportArt
|
||||
}
|
||||
};
|
||||
|
||||
const runBatchExpire = async () => {
|
||||
if (busyKey) return;
|
||||
const artifactIds = artifacts.map((artifact) => artifact.id).filter(Boolean);
|
||||
if (artifactIds.length === 0) return;
|
||||
setBusyKey('artifact-batch:mark_expire');
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await setArtifactRetentionBatch(artifactIds, 'mark_expire', { reason: 'digital_employee_daily_report_batch_retention' });
|
||||
if (!result?.ok) {
|
||||
setNotice(result?.error || '批量标记到期失败');
|
||||
return;
|
||||
}
|
||||
if (detail?.artifact.id) {
|
||||
const [item, versions] = await Promise.all([
|
||||
getArtifactDetail(detail.artifact.id),
|
||||
getArtifactVersions(detail.artifact.id),
|
||||
]);
|
||||
setDetail({ artifact: mergeArtifactDetail(detail.artifact, item), item, versions });
|
||||
}
|
||||
setNotice(`批量标记到期已记录:${result.recorded ?? 0}/${result.total ?? artifactIds.length}`);
|
||||
} catch (error) {
|
||||
setNotice(error instanceof Error ? error.message : '批量标记到期失败');
|
||||
} finally {
|
||||
setBusyKey(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="de-artifact-retention-actions">
|
||||
<button type="button" className="de-btn-secondary" disabled={busyKey !== null} onClick={() => { void runBatchExpire(); }}>批量标记到期</button>
|
||||
</div>
|
||||
<div className="de-artifact-source-list" aria-label="日报产物来源">
|
||||
{artifacts.map((artifact) => {
|
||||
const previewable = Boolean(artifact.access?.previewable);
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { CheckCircle, RefreshCw, Search, Send, ShieldCheck, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeDailyReportWorkbench, postDigitalEmployeeWorkdayAction, recordDailyReportDelivery } from '@/lib/api';
|
||||
import type { DigitalEmployeeDailyReportWorkbenchRow } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(130px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 1020, borderCollapse: 'collapse' };
|
||||
|
||||
type FilterState = { date: string; status: string; entityId: string; pageSize: number };
|
||||
const DEFAULT_FILTERS: FilterState = { date: '', status: '', entityId: '', pageSize: 20 };
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function badgeClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (/failed|error|pending/.test(text)) return 'de-badge de-badge-warning';
|
||||
if (/confirmed|sent|requested|ready/.test(text)) return 'de-badge de-badge-info';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
export default function DailyReportWorkbench({ focusEntityId }: { focusEntityId?: string | null }) {
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, entityId: focusEntityId || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeDailyReportWorkbenchRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<DigitalEmployeeDailyReportWorkbenchRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeDailyReportWorkbench({
|
||||
date: filters.date || null,
|
||||
status: filters.status || null,
|
||||
entity_id: filters.entityId || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '日报运营加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, pageNo]);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const generateReport = async (row?: DigitalEmployeeDailyReportWorkbenchRow | null) => {
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
await postDigitalEmployeeWorkdayAction({ action: 'generate_daily_report', date: row?.date || filters.date || undefined, duty_ids: [] });
|
||||
setNotice('日报已生成。');
|
||||
await loadRows();
|
||||
} catch (generateError) {
|
||||
setError(generateError instanceof Error ? generateError.message : '生成日报失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const recordDelivery = async (row: DigitalEmployeeDailyReportWorkbenchRow, action: 'send' | 'confirm' | 'request_approval') => {
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
await recordDailyReportDelivery({
|
||||
reportId: row.report_id,
|
||||
action,
|
||||
status: action === 'send' ? 'sent' : action === 'confirm' ? 'confirmed' : 'approval_requested',
|
||||
actor: 'digital_employee_operator',
|
||||
message: 'daily_report_workbench_action_recorded',
|
||||
});
|
||||
setNotice(action === 'send' ? '发送记录已写入。' : action === 'confirm' ? '确认回执已记录。' : '请求审批已记录。');
|
||||
await loadRows();
|
||||
} catch (deliveryError) {
|
||||
setError(deliveryError instanceof Error ? deliveryError.message : '日报动作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="日报运营"><span className="de-card-meta-text">{source || '日报交付运营视图'}</span></CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label><span>日期</span><input type="date" value={filters.date} onChange={(event) => updateFilter('date', event.target.value)} /></label>
|
||||
<label><span>状态</span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="ready" /></label>
|
||||
<label><span>实体</span><input value={filters.entityId} onChange={(event) => updateFilter('entityId', event.target.value)} placeholder="report id" /></label>
|
||||
<label><span>每页</span><select value={filters.pageSize} onChange={(event) => updateFilter('pageSize', Number(event.target.value) || 20)}><option value={10}>10</option><option value={20}>20</option><option value={50}>50</option></select></label>
|
||||
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
<div className="de-business-actions" aria-label="日报运营动作">
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void generateReport(); }}>生成日报</button>
|
||||
</div>
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<section style={tableWrapStyle} aria-label="日报运营列表"><table style={tableStyle} className="de-business-table"><thead><tr><th>日报</th><th>状态</th><th>发送</th><th>确认</th><th>审批</th><th>产物</th><th>操作</th></tr></thead><tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={7}>{loading ? '加载中...' : '暂无日报记录'}</td></tr> : rows.map((row) => (
|
||||
<tr key={row.report_id}>
|
||||
<td><strong>{row.title}</strong><code>{row.date} · {row.report_id}</code></td>
|
||||
<td><span className={badgeClass(row.status)}>{displayText(row.status)}</span></td>
|
||||
<td>{displayText(row.delivery_status)}</td>
|
||||
<td>{displayText(row.confirmation_status)}</td>
|
||||
<td>{displayText(row.approval_status)}</td>
|
||||
<td>{displayText(row.artifact_count, '0')}</td>
|
||||
<td className="de-business-actions"><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void recordDelivery(row, 'send'); }} title="记录发送" aria-label="记录发送"><Send size={15} /></button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void recordDelivery(row, 'confirm'); }} title="确认回执" aria-label="确认回执"><CheckCircle size={15} /></button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void recordDelivery(row, 'request_approval'); }} title="请求审批" aria-label="请求审批"><ShieldCheck size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table></section>
|
||||
<div className="de-business-pagination"><span>共 {total} 条</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button><span>{pageNo} / {pageCount}</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{detail ? <div className="de-artifact-preview" aria-label="日报运营详情"><div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -24,6 +24,14 @@ import MissionCenter from './MissionCenter';
|
||||
import SkillLibrary from './SkillLibrary';
|
||||
import DailyReport from './DailyReport';
|
||||
import BusinessView from './BusinessView';
|
||||
import InterventionWorkbench from './InterventionWorkbench';
|
||||
import MemoryGovernance from './MemoryGovernance';
|
||||
import SkillAuditWorkbench from './SkillAuditWorkbench';
|
||||
import OpsWorkbench from './OpsWorkbench';
|
||||
import NotificationWorkbench from './NotificationWorkbench';
|
||||
import DailyReportWorkbench from './DailyReportWorkbench';
|
||||
import PolicyCenter from './PolicyCenter';
|
||||
import SchedulerWorkbench from './SchedulerWorkbench';
|
||||
import OpsSettings from './OpsSettings';
|
||||
import type { DigitalNavigationTarget, DigitalTab } from './navigation';
|
||||
|
||||
@@ -33,6 +41,14 @@ const TABS: { id: DigitalTab; label: string }[] = [
|
||||
{ id: 'skills', label: '技能库' },
|
||||
{ id: 'reports', label: '日报' },
|
||||
{ id: 'business', label: '业务视图' },
|
||||
{ id: 'interventions', label: '介入台' },
|
||||
{ id: 'memory-governance', label: '记忆治理' },
|
||||
{ id: 'skill-audit', label: '技能审计' },
|
||||
{ id: 'ops-workbench', label: '处置台' },
|
||||
{ id: 'notification-workbench', label: '通知运营' },
|
||||
{ id: 'daily-report-workbench', label: '日报运营' },
|
||||
{ id: 'policy-center', label: '策略中心' },
|
||||
{ id: 'scheduler-workbench', label: '调度运营' },
|
||||
{ id: 'settings', label: '设置' },
|
||||
];
|
||||
const DIGITAL_EMPLOYEE_CLOSE_MESSAGE = 'qimingclaw:digital-close';
|
||||
@@ -90,6 +106,11 @@ function targetFromLocation(pathname: string, search: string): DigitalNavigation
|
||||
skillId: params.get('skill'),
|
||||
reportSection: params.get('section'),
|
||||
businessKind: params.get('kind'),
|
||||
interventionKind: params.get('kind'),
|
||||
memoryQuery: params.get('q'),
|
||||
auditSkillId: params.get('skill_id'),
|
||||
opsKind: params.get('kind'),
|
||||
notificationKind: params.get('kind'),
|
||||
entityId: params.get('entity'),
|
||||
};
|
||||
}
|
||||
@@ -105,9 +126,17 @@ function TabContent({
|
||||
switch (tab) {
|
||||
case 'home': return <CommandDeck onNavigate={onNavigate} />;
|
||||
case 'missions': return <MissionCenter focusMissionId={target.missionId} focusTaskId={target.taskId} focusDate={target.date} />;
|
||||
case 'skills': return <SkillLibrary focusSkillId={target.skillId} />;
|
||||
case 'skills': return <SkillLibrary focusSkillId={target.skillId} onNavigate={onNavigate} />;
|
||||
case 'reports': return <DailyReport focusSection={target.reportSection} />;
|
||||
case 'business': return <BusinessView initialKind={target.businessKind} focusEntityId={target.entityId} />;
|
||||
case 'interventions': return <InterventionWorkbench initialKind={target.interventionKind} focusEntityId={target.entityId} />;
|
||||
case 'memory-governance': return <MemoryGovernance initialQuery={target.memoryQuery} focusEntityId={target.entityId} />;
|
||||
case 'skill-audit': return <SkillAuditWorkbench focusSkillId={target.auditSkillId || target.skillId} />;
|
||||
case 'ops-workbench': return <OpsWorkbench initialKind={target.opsKind} focusEntityId={target.entityId} />;
|
||||
case 'notification-workbench': return <NotificationWorkbench initialKind={target.notificationKind} focusEntityId={target.entityId} />;
|
||||
case 'daily-report-workbench': return <DailyReportWorkbench focusEntityId={target.entityId} />;
|
||||
case 'policy-center': return <PolicyCenter />;
|
||||
case 'scheduler-workbench': return <SchedulerWorkbench />;
|
||||
case 'settings': return <OpsSettings />;
|
||||
}
|
||||
}
|
||||
@@ -290,6 +319,11 @@ export default function DigitalEmployeeShell() {
|
||||
if (nextTarget.skillId) params.set('skill', nextTarget.skillId);
|
||||
if (nextTarget.reportSection) params.set('section', nextTarget.reportSection);
|
||||
if (nextTarget.businessKind) params.set('kind', nextTarget.businessKind);
|
||||
if (nextTarget.interventionKind) params.set('kind', nextTarget.interventionKind);
|
||||
if (nextTarget.memoryQuery) params.set('q', nextTarget.memoryQuery);
|
||||
if (nextTarget.auditSkillId) params.set('skill_id', nextTarget.auditSkillId);
|
||||
if (nextTarget.opsKind) params.set('kind', nextTarget.opsKind);
|
||||
if (nextTarget.notificationKind) params.set('kind', nextTarget.notificationKind);
|
||||
if (nextTarget.entityId) params.set('entity', nextTarget.entityId);
|
||||
navigate({
|
||||
pathname: location.pathname,
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { CheckSquare, RefreshCw, Search, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import {
|
||||
decideRouteInterventionsBatch,
|
||||
getDigitalEmployeeInterventionWorkbench,
|
||||
resolveApprovalConflictsBatch,
|
||||
} from '@/lib/api';
|
||||
import type { DigitalEmployeeInterventionRow } from '@/types/api';
|
||||
|
||||
const VIEW_OPTIONS = [
|
||||
{ id: 'all', label: '全部' },
|
||||
{ id: 'approval_conflict', label: '审批冲突' },
|
||||
{ id: 'route_intervention', label: '路由干预' },
|
||||
{ id: 'risk_approval', label: '风险审批' },
|
||||
{ id: 'action_notification', label: '待处理通知' },
|
||||
] as const;
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const toolbarStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(180px, 1fr) repeat(3, minmax(120px, 160px)) auto',
|
||||
gap: 8,
|
||||
alignItems: 'end',
|
||||
marginBottom: 12,
|
||||
};
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
|
||||
|
||||
type FilterState = {
|
||||
entity: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = { entity: '', status: '', priority: '', pageSize: 20 };
|
||||
|
||||
function normalizeKind(value?: string | null): string {
|
||||
return VIEW_OPTIONS.some((item) => item.id === value) ? value || 'all' : 'all';
|
||||
}
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function rowTime(row: DigitalEmployeeInterventionRow): string {
|
||||
return displayText(row.updated_at || row.occurred_at);
|
||||
}
|
||||
|
||||
function statusClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (['failed', 'rejected', 'blocked', 'error'].some((key) => text.includes(key))) return 'de-badge de-badge-danger';
|
||||
if (['pending', 'open', 'required', 'warn'].some((key) => text.includes(key))) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
function rowApprovalId(row: DigitalEmployeeInterventionRow): string | null {
|
||||
return row.kind === 'approval_conflict' && row.approval_id ? String(row.approval_id) : null;
|
||||
}
|
||||
|
||||
function rowRouteDecisionId(row: DigitalEmployeeInterventionRow): string | null {
|
||||
return row.kind === 'route_intervention' && row.route_decision_id ? String(row.route_decision_id) : null;
|
||||
}
|
||||
|
||||
export default function InterventionWorkbench({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
|
||||
const [kind, setKind] = useState(() => normalizeKind(initialKind));
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, entity: focusEntityId || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeInterventionRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<DigitalEmployeeInterventionRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => setKind(normalizeKind(initialKind)), [initialKind]);
|
||||
useEffect(() => {
|
||||
if (focusEntityId) setFilters((current) => ({ ...current, entity: focusEntityId }));
|
||||
}, [focusEntityId]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeInterventionWorkbench({
|
||||
kind,
|
||||
entity_id: filters.entity.trim() || null,
|
||||
status: filters.status.trim() || null,
|
||||
priority: filters.priority.trim() || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.intervention_id === id)));
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '介入台加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, kind, pageNo]);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
|
||||
const selectedRows = useMemo(() => rows.filter((row) => selectedIds.includes(row.intervention_id)), [rows, selectedIds]);
|
||||
const selectedApprovalIds = useMemo(() => selectedRows.map(rowApprovalId).filter((id): id is string => Boolean(id)), [selectedRows]);
|
||||
const selectedRouteIds = useMemo(() => selectedRows.map(rowRouteDecisionId).filter((id): id is string => Boolean(id)), [selectedRows]);
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const toggleRow = (row: DigitalEmployeeInterventionRow) => {
|
||||
setSelectedIds((current) => current.includes(row.intervention_id)
|
||||
? current.filter((id) => id !== row.intervention_id)
|
||||
: [...current, row.intervention_id]);
|
||||
};
|
||||
|
||||
const runApprovalBatch = async (resolution: 'keep_local' | 'mark_reviewed') => {
|
||||
if (selectedApprovalIds.length === 0 || loading) return;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await resolveApprovalConflictsBatch(selectedApprovalIds, resolution, { actor: 'digital_employee_operator' });
|
||||
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '批量审批冲突处理失败');
|
||||
setNotice(`批量审批冲突已记录:${result.recorded}/${result.total}`);
|
||||
setSelectedIds([]);
|
||||
await loadRows();
|
||||
} catch (batchError) {
|
||||
setError(batchError instanceof Error ? batchError.message : '批量审批冲突处理失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runRouteBatch = async (decision: 'mark_reviewed' | 'dismiss') => {
|
||||
if (selectedRouteIds.length === 0 || loading) return;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await decideRouteInterventionsBatch(selectedRouteIds, { decision, actor: 'digital_employee_operator' });
|
||||
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '批量路由干预处理失败');
|
||||
setNotice(`批量路由干预已记录:${result.recorded}/${result.total}`);
|
||||
setSelectedIds([]);
|
||||
await loadRows();
|
||||
} catch (batchError) {
|
||||
setError(batchError instanceof Error ? batchError.message : '批量路由干预处理失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="介入台">
|
||||
<span className="de-card-meta-text">{source || 'qimingclaw 人工介入运营台'}</span>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div className="de-segmented-tabs" role="tablist" aria-label="介入类型">
|
||||
{VIEW_OPTIONS.map((item) => (
|
||||
<button key={item.id} type="button" className={item.id === kind ? 'active' : ''} onClick={() => { setKind(item.id); setPageNo(1); setSelectedIds([]); }}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label><span>实体 ID</span><input value={filters.entity} onChange={(event) => updateFilter('entity', event.target.value)} placeholder="approval / route / task" /></label>
|
||||
<label><span>状态</span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="open" /></label>
|
||||
<label><span>优先级</span><input value={filters.priority} onChange={(event) => updateFilter('priority', event.target.value)} placeholder="high" /></label>
|
||||
<label><span>每页</span><select value={filters.pageSize} onChange={(event) => updateFilter('pageSize', Number(event.target.value) || 20)}><option value={10}>10</option><option value={20}>20</option><option value={50}>50</option></select></label>
|
||||
<div className="de-business-actions">
|
||||
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="搜索" aria-label="搜索"><Search size={15} /></button>
|
||||
<button type="button" className="de-icon-btn" onClick={() => { setFilters(DEFAULT_FILTERS); setPageNo(1); }} title="重置" aria-label="重置"><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="de-business-actions" aria-label="批量介入动作">
|
||||
<span className="de-card-meta-text">已选择 {selectedIds.length} 项</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedApprovalIds.length === 0 || loading} onClick={() => { void runApprovalBatch('keep_local'); }}><CheckSquare size={14} /> 批量保留本地</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedApprovalIds.length === 0 || loading} onClick={() => { void runApprovalBatch('mark_reviewed'); }}>批量标记复核</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedRouteIds.length === 0 || loading} onClick={() => { void runRouteBatch('mark_reviewed'); }}>批量标记已看</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedRouteIds.length === 0 || loading} onClick={() => { void runRouteBatch('dismiss'); }}>批量忽略</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => setSelectedIds([])}>清空选择</button>
|
||||
</div>
|
||||
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
|
||||
<section style={tableWrapStyle} aria-label="介入台列表">
|
||||
<table style={tableStyle} className="de-business-table">
|
||||
<thead><tr><th>选择</th><th>类型 / 标题</th><th>状态</th><th>优先级</th><th>关联</th><th>原因</th><th>最近时间</th><th>操作</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr><td colSpan={8}>{loading ? '加载中...' : '暂无介入事项'}</td></tr>
|
||||
) : rows.map((row) => (
|
||||
<tr key={row.intervention_id} className={selectedIds.includes(row.intervention_id) ? 'active' : ''}>
|
||||
<td><input type="checkbox" checked={selectedIds.includes(row.intervention_id)} onChange={() => toggleRow(row)} aria-label="选择介入事项" /></td>
|
||||
<td><strong>{row.title}</strong><code>{row.intervention_id}</code></td>
|
||||
<td><span className={statusClass(row.status)}>{displayText(row.status)}</span></td>
|
||||
<td>{displayText(row.priority)}</td>
|
||||
<td><span>{displayText(row.plan_id)}</span><span>{displayText(row.task_id)}</span><span>{displayText(row.run_id)}</span></td>
|
||||
<td>{displayText(row.reason || row.summary)}</td>
|
||||
<td>{rowTime(row)}</td>
|
||||
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<div className="de-business-pagination">
|
||||
<span>共 {total} 条</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button>
|
||||
<span>{pageNo} / {pageCount}</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{detail ? (
|
||||
<div className="de-artifact-preview" aria-label="介入详情">
|
||||
<div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div>
|
||||
<pre>{JSON.stringify(detail, null, 2)}</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { Archive, RefreshCw, RotateCcw, Search, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeMemoryGovernance, setMemoryGovernanceStatus } from '@/lib/api';
|
||||
import type { DigitalEmployeeMemoryGovernanceRow } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'minmax(180px, 1fr) repeat(3, minmax(120px, 160px)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
|
||||
|
||||
type FilterState = { query: string; category: string; status: string; pageSize: number };
|
||||
const DEFAULT_FILTERS: FilterState = { query: '', category: '', status: '', pageSize: 20 };
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function statusClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (['archived', 'deleted', 'failed'].some((key) => text.includes(key))) return 'de-badge de-badge-danger';
|
||||
if (['review', 'pending'].some((key) => text.includes(key))) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
export default function MemoryGovernance({ initialQuery, focusEntityId }: { initialQuery?: string | null; focusEntityId?: string | null }) {
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, query: initialQuery || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeMemoryGovernanceRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [detail, setDetail] = useState<DigitalEmployeeMemoryGovernanceRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery) setFilters((current) => ({ ...current, query: initialQuery }));
|
||||
}, [initialQuery]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeMemoryGovernance({
|
||||
q: filters.query.trim() || null,
|
||||
category: filters.category.trim() || null,
|
||||
status: filters.status.trim() || null,
|
||||
entity_id: focusEntityId || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.memory_id === id)));
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '记忆治理加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, focusEntityId, pageNo]);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const toggleRow = (row: DigitalEmployeeMemoryGovernanceRow) => {
|
||||
setSelectedIds((current) => current.includes(row.memory_id) ? current.filter((id) => id !== row.memory_id) : [...current, row.memory_id]);
|
||||
};
|
||||
|
||||
const runBatch = async (action: 'archive' | 'restore' | 'mark_reviewed') => {
|
||||
if (selectedIds.length === 0 || loading) return;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await setMemoryGovernanceStatus(selectedIds, action, { actor: 'digital_employee_operator' });
|
||||
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '记忆治理操作失败');
|
||||
setNotice(`记忆治理已记录:${result.recorded}/${result.total}`);
|
||||
setSelectedIds([]);
|
||||
await loadRows();
|
||||
} catch (batchError) {
|
||||
setError(batchError instanceof Error ? batchError.message : '记忆治理操作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="记忆治理"><span className="de-card-meta-text">{source || 'qimingclaw 长期记忆治理'}</span></CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label><span>关键词</span><input value={filters.query} onChange={(event) => updateFilter('query', event.target.value)} placeholder="key / content" /></label>
|
||||
<label><span>分类</span><input value={filters.category} onChange={(event) => updateFilter('category', event.target.value)} placeholder="fact" /></label>
|
||||
<label><span>状态</span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="active" /></label>
|
||||
<label><span>每页</span><select value={filters.pageSize} onChange={(event) => updateFilter('pageSize', Number(event.target.value) || 20)}><option value={10}>10</option><option value={20}>20</option><option value={50}>50</option></select></label>
|
||||
<div className="de-business-actions"><button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="搜索" aria-label="搜索"><Search size={15} /></button><button type="button" className="de-icon-btn" onClick={() => { setFilters(DEFAULT_FILTERS); setPageNo(1); }} title="重置" aria-label="重置"><RefreshCw size={15} /></button></div>
|
||||
</div>
|
||||
<div className="de-business-actions" aria-label="记忆批量治理">
|
||||
<span className="de-card-meta-text">已选择 {selectedIds.length} 条</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('archive'); }}><Archive size={14} /> 批量归档</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('restore'); }}><RotateCcw size={14} /> 批量恢复</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('mark_reviewed'); }}>批量复核</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => setSelectedIds([])}>清空选择</button>
|
||||
</div>
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<section style={tableWrapStyle} aria-label="记忆治理列表">
|
||||
<table style={tableStyle} className="de-business-table">
|
||||
<thead><tr><th>选择</th><th>Key / 内容</th><th>状态</th><th>分类</th><th>重复候选</th><th>同步</th><th>更新时间</th><th>详情</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={8}>{loading ? '加载中...' : '暂无记忆'}</td></tr> : rows.map((row) => (
|
||||
<tr key={row.memory_id} className={selectedIds.includes(row.memory_id) ? 'active' : ''}>
|
||||
<td><input type="checkbox" checked={selectedIds.includes(row.memory_id)} onChange={() => toggleRow(row)} aria-label="选择记忆" /></td>
|
||||
<td><strong>{displayText(row.key)}</strong><code>{displayText(row.content_preview)}</code></td>
|
||||
<td><span className={statusClass(row.governance_status)}>{displayText(row.governance_status)}</span></td>
|
||||
<td>{displayText(row.category)}</td>
|
||||
<td>{row.merge_candidate_count > 0 ? `重复候选 ${row.merge_candidate_count}` : '-'}</td>
|
||||
<td>{displayText(row.sync_status)}</td>
|
||||
<td>{displayText(row.updated_at)}</td>
|
||||
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div className="de-business-pagination"><span>共 {total} 条</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button><span>{pageNo} / {pageCount}</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{detail ? <div className="de-artifact-preview" aria-label="记忆详情"><div><strong>{detail.key}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { RefreshCw, Search, Settings, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeNotificationWorkbench, replyToNotification, runDigitalEmployeeNotificationBatchAction } from '@/lib/api';
|
||||
import type { DigitalEmployeeNotificationWorkbenchRow } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(130px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
|
||||
|
||||
type FilterState = { kind: string; status: string; level: string; entityId: string; pageSize: number };
|
||||
const DEFAULT_FILTERS: FilterState = { kind: 'all', status: '', level: '', entityId: '', pageSize: 20 };
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function badgeClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (/error|failed/.test(text)) return 'de-badge de-badge-danger';
|
||||
if (/warn|action|unread/.test(text)) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
export default function NotificationWorkbench({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, kind: initialKind || 'all', entityId: focusEntityId || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeNotificationWorkbenchRow[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<DigitalEmployeeNotificationWorkbenchRow | null>(null);
|
||||
const [replyDraft, setReplyDraft] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeNotificationWorkbench({
|
||||
kind: filters.kind === 'all' ? null : filters.kind,
|
||||
status: filters.status || null,
|
||||
level: filters.level || null,
|
||||
entity_id: filters.entityId || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.notification_id === id)));
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '通知运营加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, pageNo]);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
const toggleRow = (row: DigitalEmployeeNotificationWorkbenchRow) => {
|
||||
setSelectedIds((current) => current.includes(row.notification_id) ? current.filter((id) => id !== row.notification_id) : [...current, row.notification_id]);
|
||||
};
|
||||
const runBatch = async (action: 'mark_read' | 'dismiss' | 'pull_updates' | 'open_desktop_settings') => {
|
||||
if ((action === 'mark_read' || action === 'dismiss') && selectedIds.length === 0) return;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runDigitalEmployeeNotificationBatchAction(selectedIds, action, { actor: 'digital_employee_operator' });
|
||||
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '通知运营操作失败');
|
||||
setNotice(action === 'open_desktop_settings' ? '已请求打开系统设置。' : `通知运营已记录:${result.recorded}/${result.total}`);
|
||||
if (action !== 'open_desktop_settings') setSelectedIds([]);
|
||||
await loadRows();
|
||||
} catch (batchError) {
|
||||
setError(batchError instanceof Error ? batchError.message : '通知运营操作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
const submitReply = async () => {
|
||||
if (!detail?.notification_id || !replyDraft.trim()) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await replyToNotification(detail.notification_id, replyDraft.trim());
|
||||
setNotice('回复已记录。');
|
||||
setReplyDraft('');
|
||||
await loadRows();
|
||||
} catch (replyError) {
|
||||
setError(replyError instanceof Error ? replyError.message : '回复失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="通知运营"><span className="de-card-meta-text">{source || 'Inbox 运营视图'}</span></CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label><span>类型</span><input value={filters.kind} onChange={(event) => updateFilter('kind', event.target.value || 'all')} placeholder="all / need_input" /></label>
|
||||
<label><span>状态</span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="unread" /></label>
|
||||
<label><span>等级</span><input value={filters.level} onChange={(event) => updateFilter('level', event.target.value)} placeholder="action" /></label>
|
||||
<label><span>实体</span><input value={filters.entityId} onChange={(event) => updateFilter('entityId', event.target.value)} placeholder="notification id" /></label>
|
||||
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
<div className="de-business-actions" aria-label="通知批量动作">
|
||||
<span className="de-card-meta-text">已选择 {selectedIds.length} 条</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('mark_read'); }}>批量已读</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('dismiss'); }}>批量忽略</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void runBatch('pull_updates'); }}>拉取通知</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void runBatch('open_desktop_settings'); }}><Settings size={14} /> 打开系统设置</button>
|
||||
</div>
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<section style={tableWrapStyle} aria-label="通知运营列表"><table style={tableStyle} className="de-business-table"><thead><tr><th>选择</th><th>通知</th><th>类型</th><th>状态</th><th>等级</th><th>时间</th><th>详情</th></tr></thead><tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={7}>{loading ? '加载中...' : '暂无通知'}</td></tr> : rows.map((row) => (
|
||||
<tr key={row.notification_id} className={selectedIds.includes(row.notification_id) ? 'active' : ''}>
|
||||
<td><input type="checkbox" checked={selectedIds.includes(row.notification_id)} onChange={() => toggleRow(row)} aria-label="选择通知" /></td>
|
||||
<td><strong>{row.title}</strong><code>{row.notification_id}</code></td>
|
||||
<td>{displayText(row.kind)}</td>
|
||||
<td><span className={badgeClass(row.status)}>{displayText(row.status)}</span></td>
|
||||
<td><span className={badgeClass(row.priority)}>{displayText(row.priority)}</span></td>
|
||||
<td>{displayText(row.updated_at || row.occurred_at)}</td>
|
||||
<td><button type="button" className="de-icon-btn" onClick={() => { setDetail(row); setReplyDraft(''); }} title="详情" aria-label="详情"><Search size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table></section>
|
||||
<div className="de-business-pagination"><span>共 {total} 条</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button><span>{pageNo} / {pageCount}</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{detail ? <div className="de-artifact-preview" aria-label="通知详情"><div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre><textarea value={replyDraft} onChange={(event) => setReplyDraft(event.target.value)} placeholder="回复单条通知" /><button type="button" className="de-btn-secondary de-btn-sm" disabled={!replyDraft.trim() || loading} onClick={() => { void submitReply(); }}>回复</button></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -244,7 +244,7 @@ export default function OpsSettings() {
|
||||
|
||||
<section className="de-settings-section">
|
||||
<h3 className="de-settings-heading">风险审批策略</h3>
|
||||
<p className="de-settings-desc">调整客户端高风险动作在执行前是否需要人工审批,或直接拒绝。</p>
|
||||
<p className="de-settings-desc">调整客户端高风险动作在执行前是否需要人工审批,或直接拒绝;更完整的派发、路由和技能策略可在策略中心统一查看。</p>
|
||||
<div style={fieldGridStyle}>
|
||||
<label style={fieldStyle}>
|
||||
<span className="de-role-label">启用策略</span>
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { RefreshCw, Search, ShieldCheck, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeOpsWorkbench, runDigitalEmployeeOpsBatchAction } from '@/lib/api';
|
||||
import type { DigitalEmployeeOpsWorkbenchRow } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(4, minmax(130px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
|
||||
|
||||
type FilterState = { kind: string; status: string; priority: string; entityId: string; pageSize: number };
|
||||
const DEFAULT_FILTERS: FilterState = { kind: 'all', status: '', priority: '', entityId: '', pageSize: 20 };
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function badgeClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (/error|failed|stopped|degraded/.test(text)) return 'de-badge de-badge-danger';
|
||||
if (/warn|pending|review/.test(text)) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
export default function OpsWorkbench({ initialKind, focusEntityId }: { initialKind?: string | null; focusEntityId?: string | null }) {
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, kind: initialKind || 'all', entityId: focusEntityId || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeOpsWorkbenchRow[]>([]);
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<DigitalEmployeeOpsWorkbenchRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeOpsWorkbench({
|
||||
kind: filters.kind === 'all' ? null : filters.kind,
|
||||
status: filters.status || null,
|
||||
priority: filters.priority || null,
|
||||
entity_id: filters.entityId || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
setSelectedIds((current) => current.filter((id) => (data.items ?? []).some((row) => row.ops_id === id)));
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '处置台加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, pageNo]);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const toggleRow = (row: DigitalEmployeeOpsWorkbenchRow) => {
|
||||
setSelectedIds((current) => current.includes(row.ops_id) ? current.filter((id) => id !== row.ops_id) : [...current, row.ops_id]);
|
||||
};
|
||||
|
||||
const runBatch = async (action: 'mark_reviewed' | 'dismiss' | 'retry_policy_pull' | 'safe_restart_service') => {
|
||||
if (selectedIds.length === 0 || loading) return;
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await runDigitalEmployeeOpsBatchAction(selectedIds, action, { actor: 'digital_employee_operator' });
|
||||
if (!result.ok) throw new Error(result.error || result.reason_codes?.join(', ') || '处置动作失败');
|
||||
setNotice(`处置台已记录:${result.recorded}/${result.total}`);
|
||||
setSelectedIds([]);
|
||||
await loadRows();
|
||||
} catch (batchError) {
|
||||
setError(batchError instanceof Error ? batchError.message : '处置动作失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="处置台"><span className="de-card-meta-text">{source || '诊断、服务、同步和策略处置'}</span></CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label><span>类型</span><select value={filters.kind} onChange={(event) => updateFilter('kind', event.target.value)}><option value="all">全部</option><option value="diagnostic">诊断</option><option value="service">服务</option><option value="sync">同步</option><option value="policy">策略</option><option value="audit">审计</option></select></label>
|
||||
<label><span>状态</span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="error" /></label>
|
||||
<label><span>优先级</span><input value={filters.priority} onChange={(event) => updateFilter('priority', event.target.value)} placeholder="warn" /></label>
|
||||
<label><span>实体</span><input value={filters.entityId} onChange={(event) => updateFilter('entityId', event.target.value)} placeholder="service / policy" /></label>
|
||||
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
<div className="de-business-actions" aria-label="处置台批量动作">
|
||||
<span className="de-card-meta-text">已选择 {selectedIds.length} 条</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('mark_reviewed'); }}>批量标记已看</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('dismiss'); }}>批量忽略</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('retry_policy_pull'); }}>策略重拉</button>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={selectedIds.length === 0 || loading} onClick={() => { void runBatch('safe_restart_service'); }}><ShieldCheck size={14} /> 安全重启</button>
|
||||
</div>
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<section style={tableWrapStyle} aria-label="处置事项列表">
|
||||
<table style={tableStyle} className="de-business-table"><thead><tr><th>选择</th><th>事项</th><th>类型</th><th>状态</th><th>建议</th><th>时间</th><th>详情</th></tr></thead><tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={7}>{loading ? '加载中...' : '暂无处置事项'}</td></tr> : rows.map((row) => (
|
||||
<tr key={row.ops_id} className={selectedIds.includes(row.ops_id) ? 'active' : ''}>
|
||||
<td><input type="checkbox" checked={selectedIds.includes(row.ops_id)} onChange={() => toggleRow(row)} aria-label="选择处置事项" /></td>
|
||||
<td><strong>{row.title}</strong><code>{row.ops_id}</code></td>
|
||||
<td>{displayText(row.kind)}</td>
|
||||
<td><span className={badgeClass(row.status || row.priority)}>{displayText(row.status)}</span></td>
|
||||
<td>{displayText(row.recommended_action)}</td>
|
||||
<td>{displayText(row.updated_at || row.occurred_at)}</td>
|
||||
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table>
|
||||
</section>
|
||||
<div className="de-business-pagination"><span>共 {total} 条</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button><span>{pageNo} / {pageCount}</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{detail ? <div className="de-artifact-preview" aria-label="处置详情"><div><strong>{detail.title}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { useCallback, useEffect, useState, type CSSProperties } from 'react';
|
||||
import { RefreshCw } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import {
|
||||
getDigitalEmployeePolicyCenter,
|
||||
patchPlanStepDispatchPolicy,
|
||||
patchRiskApprovalPolicy,
|
||||
pullDigitalEmployeePoliciesBatch,
|
||||
} from '@/lib/api';
|
||||
import type { DigitalEmployeePolicyCenterRow, PlanStepDispatchPolicy, RiskApprovalPolicy } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const gridStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 12 };
|
||||
const panelStyle: CSSProperties = { border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8, padding: 12, display: 'grid', gap: 10 };
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function badgeClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (/error|failed|disabled/.test(text)) return 'de-badge de-badge-danger';
|
||||
if (/pull|pending|remote/.test(text)) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
export default function PolicyCenter() {
|
||||
const [rows, setRows] = useState<DigitalEmployeePolicyCenterRow[]>([]);
|
||||
const [dispatchPolicy, setDispatchPolicy] = useState<PlanStepDispatchPolicy | null>(null);
|
||||
const [riskPolicy, setRiskPolicy] = useState<RiskApprovalPolicy | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadPolicyCenter = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeePolicyCenter();
|
||||
setRows(data.items ?? []);
|
||||
setDispatchPolicy(data.plan_step_dispatch_policy);
|
||||
setRiskPolicy(data.risk_approval_policy);
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setError(loadError instanceof Error ? loadError.message : '策略中心加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadPolicyCenter(); }, [loadPolicyCenter]);
|
||||
|
||||
const pullAll = async () => {
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await pullDigitalEmployeePoliciesBatch();
|
||||
if (!result.ok) throw new Error(result.items.map((item) => item.error).filter(Boolean).join(', ') || '批量拉取策略失败');
|
||||
setNotice(`批量拉取策略完成:${result.pulled}/${result.total}`);
|
||||
await loadPolicyCenter();
|
||||
} catch (pullError) {
|
||||
setError(pullError instanceof Error ? pullError.message : '批量拉取策略失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDispatch = async (patch: Partial<PlanStepDispatchPolicy>) => {
|
||||
if (!dispatchPolicy) return;
|
||||
const next = await patchPlanStepDispatchPolicy(patch);
|
||||
setDispatchPolicy(next);
|
||||
setNotice('派发策略已保存。');
|
||||
await loadPolicyCenter();
|
||||
};
|
||||
|
||||
const updateRisk = async (patch: Partial<RiskApprovalPolicy>) => {
|
||||
if (!riskPolicy) return;
|
||||
const next = await patchRiskApprovalPolicy(patch);
|
||||
setRiskPolicy(next);
|
||||
setNotice('风险审批策略已保存。');
|
||||
await loadPolicyCenter();
|
||||
};
|
||||
|
||||
const updateRiskCsv = (field: 'approval_required_risk_levels' | 'approval_required_actions' | 'auto_reject_actions', value: string) => {
|
||||
void updateRisk({ [field]: value.split(',').map((item) => item.trim()).filter(Boolean) } as Partial<RiskApprovalPolicy>);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="策略中心">
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void pullAll(); }}>批量拉取策略</button>
|
||||
<button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void loadPolicyCenter(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<div style={gridStyle}>
|
||||
{rows.map((row) => <section key={row.policy_id} style={panelStyle}><strong>{row.title}</strong><span className={badgeClass(row.status)}>{row.status}</span><small>{displayText(row.summary)}</small><small>source: {displayText(row.source)} · pulled: {displayText(row.last_pulled_at)}</small>{row.last_pull_error ? <small>{row.last_pull_error}</small> : null}</section>)}
|
||||
</div>
|
||||
{dispatchPolicy ? <section style={panelStyle}>
|
||||
<h3 className="de-settings-heading">派发策略</h3>
|
||||
<label><span>enabled</span><select value={dispatchPolicy.enabled ? 'true' : 'false'} onChange={(event) => { void updateDispatch({ enabled: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
|
||||
<label><span>max_per_sweep</span><input type="number" min={1} max={10} value={dispatchPolicy.max_per_sweep} onChange={(event) => { void updateDispatch({ max_per_sweep: Number(event.target.value) || 1 }); }} /></label>
|
||||
<label><span>auto_dispatch_ready_steps</span><select value={dispatchPolicy.auto_dispatch_ready_steps ? 'true' : 'false'} onChange={(event) => { void updateDispatch({ auto_dispatch_ready_steps: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
|
||||
</section> : null}
|
||||
{riskPolicy ? <section style={panelStyle}>
|
||||
<h3 className="de-settings-heading">风险审批策略</h3>
|
||||
<label><span>enabled</span><select value={riskPolicy.enabled ? 'true' : 'false'} onChange={(event) => { void updateRisk({ enabled: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
|
||||
<label><span>approval_required_risk_levels</span><input value={riskPolicy.approval_required_risk_levels.join(',')} onChange={(event) => updateRiskCsv('approval_required_risk_levels', event.target.value)} /></label>
|
||||
<label><span>approval_required_actions</span><input value={riskPolicy.approval_required_actions.join(',')} onChange={(event) => updateRiskCsv('approval_required_actions', event.target.value)} /></label>
|
||||
<label><span>auto_reject_actions</span><input value={riskPolicy.auto_reject_actions.join(',')} onChange={(event) => updateRiskCsv('auto_reject_actions', event.target.value)} /></label>
|
||||
</section> : null}
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { Play, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { deleteCronJob, getDigitalEmployeeSchedulerWorkbench, patchCronJob, patchCronSettings, triggerCronCheck } from '@/lib/api';
|
||||
import type { DigitalEmployeeSchedulerWorkbenchRow } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 980, borderCollapse: 'collapse' };
|
||||
const settingsStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 10, marginBottom: 12 };
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function badgeClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (/failed|error|deleted/.test(text)) return 'de-badge de-badge-danger';
|
||||
if (/paused|pending|skipped/.test(text)) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
export default function SchedulerWorkbench() {
|
||||
const [rows, setRows] = useState<DigitalEmployeeSchedulerWorkbenchRow[]>([]);
|
||||
const [settings, setSettings] = useState({ enabled: false, catch_up_on_startup: false, max_run_history: 20 });
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeSchedulerWorkbench();
|
||||
setRows(data.items ?? []);
|
||||
setSettings(data.settings);
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setError(loadError instanceof Error ? loadError.message : '调度运营加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
const enabledCount = useMemo(() => rows.filter((row) => row.enabled).length, [rows]);
|
||||
|
||||
const updateSettings = async (patch: Partial<typeof settings>) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await patchCronSettings(patch);
|
||||
setSettings(next);
|
||||
setNotice('调度设置已保存。');
|
||||
await loadRows();
|
||||
} catch (settingsError) {
|
||||
setError(settingsError instanceof Error ? settingsError.message : '调度设置保存失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkNow = async () => {
|
||||
setLoading(true);
|
||||
setNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await triggerCronCheck();
|
||||
setNotice(`立即检查调度完成:activated ${result.activated ?? 0},skipped ${result.skipped ?? 0}`);
|
||||
await loadRows();
|
||||
} catch (checkError) {
|
||||
setError(checkError instanceof Error ? checkError.message : '立即检查调度失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleJob = async (row: DigitalEmployeeSchedulerWorkbenchRow) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await patchCronJob(row.schedule_id, { enabled: !row.enabled });
|
||||
setNotice(row.enabled ? '调度已暂停。' : '调度已启用。');
|
||||
await loadRows();
|
||||
} catch (toggleError) {
|
||||
setError(toggleError instanceof Error ? toggleError.message : '调度启停失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const removeJob = async (row: DigitalEmployeeSchedulerWorkbenchRow) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
await deleteCronJob(row.schedule_id);
|
||||
setNotice('调度已删除。');
|
||||
await loadRows();
|
||||
} catch (deleteError) {
|
||||
setError(deleteError instanceof Error ? deleteError.message : '删除调度失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="调度运营">
|
||||
<span className="de-card-meta-text">启用 {enabledCount}/{rows.length}</span>
|
||||
<button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void checkNow(); }}><Play size={14} /> 立即检查调度</button>
|
||||
<button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void loadRows(); }} title="刷新" aria-label="刷新"><RefreshCw size={15} /></button>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div style={settingsStyle} className="de-business-toolbar">
|
||||
<label><span>enabled</span><select value={settings.enabled ? 'true' : 'false'} onChange={(event) => { void updateSettings({ enabled: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
|
||||
<label><span>catch_up_on_startup</span><select value={settings.catch_up_on_startup ? 'true' : 'false'} onChange={(event) => { void updateSettings({ catch_up_on_startup: event.target.value === 'true' }); }}><option value="true">true</option><option value="false">false</option></select></label>
|
||||
<label><span>max_run_history</span><input type="number" min={1} max={500} value={settings.max_run_history} onChange={(event) => { void updateSettings({ max_run_history: Number(event.target.value) || 20 }); }} /></label>
|
||||
</div>
|
||||
{notice ? <p className="de-inline-notice">{notice}</p> : null}
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<section style={tableWrapStyle} aria-label="调度运营列表"><table style={tableStyle} className="de-business-table"><thead><tr><th>任务</th><th>状态</th><th>Cron</th><th>下次</th><th>最近运行</th><th>最近结果</th><th>操作</th></tr></thead><tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={7}>{loading ? '加载中...' : '暂无调度任务'}</td></tr> : rows.map((row) => (
|
||||
<tr key={row.schedule_id}>
|
||||
<td><strong>{row.name}</strong><code>{row.schedule_id}</code></td>
|
||||
<td><span className={badgeClass(row.status)}>{displayText(row.status)}</span></td>
|
||||
<td>{displayText(row.cron_expr)}</td>
|
||||
<td>{displayText(row.next_run)}</td>
|
||||
<td>{displayText(row.last_run)}</td>
|
||||
<td>{displayText(row.last_status || row.latest_run_error)}</td>
|
||||
<td className="de-business-actions"><button type="button" className="de-btn-secondary de-btn-sm" disabled={loading} onClick={() => { void toggleJob(row); }}>{row.enabled ? '暂停' : '启用'}</button><button type="button" className="de-icon-btn" disabled={loading} onClick={() => { void removeJob(row); }} title="删除" aria-label="删除"><Trash2 size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody></table></section>
|
||||
</div>
|
||||
</GlassCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react';
|
||||
import { Filter, RefreshCw, Search, X } from 'lucide-react';
|
||||
import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { getDigitalEmployeeSkillAuditWorkbench } from '@/lib/api';
|
||||
import type { DigitalEmployeeSkillAuditWorkbenchRow } from '@/types/api';
|
||||
|
||||
const pageStyle: CSSProperties = { padding: '14px 18px' };
|
||||
const toolbarStyle: CSSProperties = { display: 'grid', gridTemplateColumns: 'repeat(6, minmax(120px, 1fr)) auto', gap: 8, alignItems: 'end', marginBottom: 12 };
|
||||
const tableWrapStyle: CSSProperties = { overflow: 'auto', border: '1px solid var(--de-glass-border-lighter)', borderRadius: 8 };
|
||||
const tableStyle: CSSProperties = { width: '100%', minWidth: 1040, borderCollapse: 'collapse' };
|
||||
|
||||
type FilterState = {
|
||||
skillId: string;
|
||||
toolName: string;
|
||||
serverId: string;
|
||||
decision: string;
|
||||
category: string;
|
||||
status: string;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
const DEFAULT_FILTERS: FilterState = {
|
||||
skillId: '',
|
||||
toolName: '',
|
||||
serverId: '',
|
||||
decision: '',
|
||||
category: '',
|
||||
status: '',
|
||||
pageSize: 20,
|
||||
};
|
||||
|
||||
function displayText(value: unknown, fallback = '-'): string {
|
||||
if (value === null || value === undefined || value === '') return fallback;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function decisionLabel(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (text.includes('allow') || text.includes('accepted')) return '允许';
|
||||
if (text.includes('reject') || text.includes('deny') || text.includes('blocked')) return '拒绝';
|
||||
if (text.includes('observe')) return '观察';
|
||||
return displayText(value, '观察');
|
||||
}
|
||||
|
||||
function badgeClass(value: unknown): string {
|
||||
const text = String(value || '').toLowerCase();
|
||||
if (/reject|deny|blocked|failed|error/.test(text)) return 'de-badge de-badge-danger';
|
||||
if (/warn|review|pending|timeout/.test(text)) return 'de-badge de-badge-warning';
|
||||
return 'de-badge de-badge-info';
|
||||
}
|
||||
|
||||
function formatCost(value: unknown): string {
|
||||
const numberValue = typeof value === 'number' ? value : Number(value);
|
||||
if (!Number.isFinite(numberValue) || numberValue <= 0) return '-';
|
||||
if (numberValue < 0.01) return `$${numberValue.toFixed(4)}`;
|
||||
return `$${numberValue.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function SkillAuditWorkbench({ focusSkillId }: { focusSkillId?: string | null }) {
|
||||
const [filters, setFilters] = useState<FilterState>({ ...DEFAULT_FILTERS, skillId: focusSkillId || '' });
|
||||
const [pageNo, setPageNo] = useState(1);
|
||||
const [rows, setRows] = useState<DigitalEmployeeSkillAuditWorkbenchRow[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [source, setSource] = useState<string | null>(null);
|
||||
const [detail, setDetail] = useState<DigitalEmployeeSkillAuditWorkbenchRow | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (focusSkillId) setFilters((current) => ({ ...current, skillId: focusSkillId }));
|
||||
}, [focusSkillId]);
|
||||
|
||||
const loadRows = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getDigitalEmployeeSkillAuditWorkbench({
|
||||
skill_id: filters.skillId.trim() || null,
|
||||
tool_name: filters.toolName.trim() || null,
|
||||
server_id: filters.serverId.trim() || null,
|
||||
decision: filters.decision.trim() || null,
|
||||
category: filters.category.trim() || null,
|
||||
status: filters.status.trim() || null,
|
||||
page_no: pageNo,
|
||||
page_size: filters.pageSize,
|
||||
});
|
||||
setRows(data.items ?? []);
|
||||
setTotal(data.total ?? 0);
|
||||
setSource(data.source ?? null);
|
||||
} catch (loadError) {
|
||||
setRows([]);
|
||||
setTotal(0);
|
||||
setError(loadError instanceof Error ? loadError.message : '技能审计加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filters, pageNo]);
|
||||
|
||||
useEffect(() => { void loadRows(); }, [loadRows]);
|
||||
|
||||
const pageCount = useMemo(() => Math.max(1, Math.ceil(total / filters.pageSize)), [filters.pageSize, total]);
|
||||
|
||||
const updateFilter = <K extends keyof FilterState>(key: K, value: FilterState[K]) => {
|
||||
setFilters((current) => ({ ...current, [key]: value }));
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setFilters({ ...DEFAULT_FILTERS, skillId: focusSkillId || '' });
|
||||
setPageNo(1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={pageStyle}>
|
||||
<GlassCard>
|
||||
<CardTitleBar title="技能审计">
|
||||
<span className="de-card-meta-text">{source || '技能调用、工具、sandbox 与 token/cost 审计'}</span>
|
||||
</CardTitleBar>
|
||||
<div className="de-card-body">
|
||||
<div style={toolbarStyle} className="de-business-toolbar">
|
||||
<label><span>Skill</span><input value={filters.skillId} onChange={(event) => updateFilter('skillId', event.target.value)} placeholder="skill id" /></label>
|
||||
<label><span>Tool</span><input value={filters.toolName} onChange={(event) => updateFilter('toolName', event.target.value)} placeholder="tool name" /></label>
|
||||
<label><span>Server</span><input value={filters.serverId} onChange={(event) => updateFilter('serverId', event.target.value)} placeholder="server id" /></label>
|
||||
<label><span>决策</span><input value={filters.decision} onChange={(event) => updateFilter('decision', event.target.value)} placeholder="允许 / 拒绝 / 观察" /></label>
|
||||
<label><span>分类</span><input value={filters.category} onChange={(event) => updateFilter('category', event.target.value)} placeholder="tool_call_audit" /></label>
|
||||
<label><span>状态</span><input value={filters.status} onChange={(event) => updateFilter('status', event.target.value)} placeholder="status" /></label>
|
||||
<div className="de-business-actions">
|
||||
<button type="button" className="de-icon-btn" onClick={() => { void loadRows(); }} title="筛选" aria-label="筛选"><Filter size={15} /></button>
|
||||
<button type="button" className="de-icon-btn" onClick={resetFilters} title="重置" aria-label="重置"><RefreshCw size={15} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="de-business-actions" aria-label="技能审计筛选">
|
||||
<span className="de-card-meta-text">共 {total} 条</span>
|
||||
<label><span>每页</span><select value={filters.pageSize} onChange={(event) => updateFilter('pageSize', Number(event.target.value) || 20)}><option value={10}>10</option><option value={20}>20</option><option value={50}>50</option></select></label>
|
||||
</div>
|
||||
{error ? <p className="de-inline-notice">{error}</p> : null}
|
||||
<section style={tableWrapStyle} aria-label="技能审计列表">
|
||||
<table style={tableStyle} className="de-business-table">
|
||||
<thead><tr><th>审计</th><th>决策</th><th>Skill / Tool</th><th>Server</th><th>风险</th><th>Token / 成本</th><th>时间</th><th>详情</th></tr></thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? <tr><td colSpan={8}>{loading ? '加载中...' : '暂无技能审计记录'}</td></tr> : rows.map((row) => (
|
||||
<tr key={row.audit_id}>
|
||||
<td><strong>{displayText(row.category)}</strong><code>{displayText(row.audit_id)}</code></td>
|
||||
<td><span className={badgeClass(row.decision || row.status)}>{decisionLabel(row.decision || row.status)}</span></td>
|
||||
<td><strong>{displayText(row.skill_id)}</strong><code>{displayText(row.tool_name)}</code></td>
|
||||
<td>{displayText(row.server_id)}</td>
|
||||
<td><span className={badgeClass(row.risk_level || row.status)}>{displayText(row.risk_level || row.status)}</span></td>
|
||||
<td>{displayText(row.total_tokens, '0')} / {formatCost(row.estimated_cost_usd)}</td>
|
||||
<td>{displayText(row.occurred_at || row.updated_at)}</td>
|
||||
<td><button type="button" className="de-icon-btn" onClick={() => setDetail(row)} title="详情" aria-label="详情"><Search size={15} /></button></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
<div className="de-business-pagination"><span>{pageNo} / {pageCount}</span><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo <= 1} onClick={() => setPageNo((page) => Math.max(1, page - 1))}>上一页</button><button type="button" className="de-btn-secondary de-btn-sm" disabled={pageNo >= pageCount} onClick={() => setPageNo((page) => Math.min(pageCount, page + 1))}>下一页</button></div>
|
||||
</div>
|
||||
</GlassCard>
|
||||
{detail ? <div className="de-artifact-preview" aria-label="技能审计详情"><div><strong>{detail.category}</strong><button type="button" className="de-icon-btn" onClick={() => setDetail(null)} title="关闭" aria-label="关闭"><X size={15} /></button></div><pre>{JSON.stringify(detail, null, 2)}</pre></div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import GlassCard from '@/components/digital/GlassCard';
|
||||
import CardTitleBar from '@/components/digital/CardTitleBar';
|
||||
import { adaptSkillSummaries, type SkillSummary } from '@/lib/consoleDataAdapter';
|
||||
import { getSkills, pullSkillPolicies, setSkillEnabled } from '@/lib/api';
|
||||
import { domSafeId } from './navigation';
|
||||
import { domSafeId, type DigitalNavigationTarget } from './navigation';
|
||||
|
||||
type TaskCadence = 'daily' | 'weekly' | 'monthly' | 'adhoc';
|
||||
type SkillSection = {
|
||||
@@ -107,6 +107,7 @@ function SkillCard({
|
||||
updating = false,
|
||||
onSelect,
|
||||
onToggle,
|
||||
onAudit,
|
||||
}: {
|
||||
skill: SkillSummary;
|
||||
recommended?: boolean;
|
||||
@@ -115,6 +116,7 @@ function SkillCard({
|
||||
updating?: boolean;
|
||||
onSelect?: (skill: SkillSummary) => void;
|
||||
onToggle?: (skill: SkillSummary) => void;
|
||||
onAudit?: (skill: SkillSummary) => void;
|
||||
}) {
|
||||
const cadence = getCadence(skill);
|
||||
const status = learningStatus(skill, recommended, advanced);
|
||||
@@ -163,12 +165,22 @@ function SkillCard({
|
||||
>
|
||||
{updating ? '处理中...' : skill.enabled ? '暂不使用' : '恢复使用'}
|
||||
</button>
|
||||
<button
|
||||
className="de-skill-btn"
|
||||
title="查看该技能的调用审计。"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onAudit?.(skill);
|
||||
}}
|
||||
>
|
||||
技能审计
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string | null }) {
|
||||
export default function SkillLibrary({ focusSkillId, onNavigate }: { focusSkillId?: string | null; onNavigate?: (target: DigitalNavigationTarget) => void }) {
|
||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
@@ -332,6 +344,7 @@ export default function SkillLibrary({ focusSkillId }: { focusSkillId?: string |
|
||||
updating={updatingSkillId === s.id}
|
||||
onSelect={(skill) => setSelectedSkillId(skill.id)}
|
||||
onToggle={toggleSkill}
|
||||
onAudit={(skill) => onNavigate?.({ tab: 'skill-audit', auditSkillId: skill.id })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'business' | 'settings';
|
||||
export type DigitalTab = 'home' | 'missions' | 'skills' | 'reports' | 'business' | 'interventions' | 'memory-governance' | 'skill-audit' | 'ops-workbench' | 'notification-workbench' | 'daily-report-workbench' | 'policy-center' | 'scheduler-workbench' | 'settings';
|
||||
|
||||
export type DigitalNavigationTarget = {
|
||||
tab: DigitalTab;
|
||||
@@ -8,6 +8,11 @@ export type DigitalNavigationTarget = {
|
||||
skillId?: string | null;
|
||||
reportSection?: string | null;
|
||||
businessKind?: string | null;
|
||||
interventionKind?: string | null;
|
||||
memoryQuery?: string | null;
|
||||
auditSkillId?: string | null;
|
||||
opsKind?: string | null;
|
||||
notificationKind?: string | null;
|
||||
entityId?: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -332,7 +332,231 @@ export interface BusinessViewResponse<T> {
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeBusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'approvals';
|
||||
export type DigitalEmployeeInterventionKind = 'approval_conflict' | 'route_intervention' | 'risk_approval' | 'action_notification' | string;
|
||||
|
||||
export interface DigitalEmployeeInterventionRow extends Record<string, unknown> {
|
||||
intervention_id: string;
|
||||
kind: DigitalEmployeeInterventionKind;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: 'normal' | 'medium' | 'high' | string;
|
||||
entity_type?: string | null;
|
||||
entity_id?: string | null;
|
||||
approval_id?: string | null;
|
||||
route_decision_id?: string | null;
|
||||
notification_id?: string | null;
|
||||
event_id?: string | null;
|
||||
plan_id?: string | null;
|
||||
task_id?: string | null;
|
||||
run_id?: string | null;
|
||||
reason?: string | null;
|
||||
summary?: string | null;
|
||||
occurred_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeInterventionWorkbenchResponse {
|
||||
items: DigitalEmployeeInterventionRow[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeBatchActionResult {
|
||||
ok: boolean;
|
||||
total: number;
|
||||
recorded: number;
|
||||
rejected: number;
|
||||
items: Array<Record<string, unknown>>;
|
||||
reason_codes?: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeMemoryGovernanceRow extends Record<string, unknown> {
|
||||
memory_id: string;
|
||||
key: string;
|
||||
category: string;
|
||||
source?: string | null;
|
||||
status: string;
|
||||
governance_status: string;
|
||||
last_governance_action?: string | null;
|
||||
last_reviewed_at?: string | null;
|
||||
content_preview: string;
|
||||
score?: number | null;
|
||||
session_id?: string | null;
|
||||
sync_status?: string | null;
|
||||
merge_candidate_count: number;
|
||||
duplicate_memory_ids: string[];
|
||||
latest_memory_id?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeMemoryGovernanceResponse {
|
||||
items: DigitalEmployeeMemoryGovernanceRow[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillAuditWorkbenchRow extends Record<string, unknown> {
|
||||
audit_id: string;
|
||||
category: string;
|
||||
decision?: string | null;
|
||||
status: string;
|
||||
skill_id?: string | null;
|
||||
tool_name?: string | null;
|
||||
server_id?: string | null;
|
||||
source?: string | null;
|
||||
risk_level?: string | null;
|
||||
model?: string | null;
|
||||
total_tokens?: number | null;
|
||||
estimated_cost_usd?: number | null;
|
||||
session_id?: string | null;
|
||||
task_id?: string | null;
|
||||
run_id?: string | null;
|
||||
message?: string | null;
|
||||
occurred_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSkillAuditWorkbenchResponse {
|
||||
items: DigitalEmployeeSkillAuditWorkbenchRow[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeWorkbenchBatchResult {
|
||||
ok: boolean;
|
||||
action: string;
|
||||
total: number;
|
||||
recorded: number;
|
||||
rejected: number;
|
||||
items: Array<Record<string, unknown>>;
|
||||
reason_codes?: string[];
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeOpsWorkbenchRow extends Record<string, unknown> {
|
||||
ops_id: string;
|
||||
kind: 'diagnostic' | 'service' | 'sync' | 'policy' | 'audit' | string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: 'info' | 'warn' | 'error' | 'action' | string;
|
||||
entity_id?: string | null;
|
||||
service_id?: string | null;
|
||||
recommended_action?: string | null;
|
||||
reason_codes?: string[];
|
||||
occurred_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeOpsWorkbenchResponse {
|
||||
items: DigitalEmployeeOpsWorkbenchRow[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeNotificationWorkbenchRow extends Record<string, unknown> {
|
||||
notification_id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
level?: string | null;
|
||||
entity_id?: string | null;
|
||||
recommended_action?: string | null;
|
||||
occurred_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeNotificationWorkbenchResponse {
|
||||
items: DigitalEmployeeNotificationWorkbenchRow[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDailyReportWorkbenchRow extends Record<string, unknown> {
|
||||
report_id: string;
|
||||
date: string;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
delivery_status?: string | null;
|
||||
confirmation_status?: string | null;
|
||||
approval_status?: string | null;
|
||||
artifact_count?: number | null;
|
||||
recommended_action?: string | null;
|
||||
occurred_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeDailyReportWorkbenchResponse {
|
||||
items: DigitalEmployeeDailyReportWorkbenchRow[];
|
||||
page_no: number;
|
||||
page_size: number;
|
||||
total: number;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePolicyCenterRow extends Record<string, unknown> {
|
||||
policy_id: 'plan_step_dispatch' | 'risk_approval' | 'route_decision' | 'skill' | string;
|
||||
title: string;
|
||||
status: string;
|
||||
source?: string | null;
|
||||
updated_at?: string | null;
|
||||
last_pulled_at?: string | null;
|
||||
last_pull_error?: string | null;
|
||||
summary?: string | null;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePolicyCenterResponse {
|
||||
items: DigitalEmployeePolicyCenterRow[];
|
||||
plan_step_dispatch_policy: PlanStepDispatchPolicy;
|
||||
risk_approval_policy: RiskApprovalPolicy;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeePolicyPullBatchResult {
|
||||
ok: boolean;
|
||||
total: number;
|
||||
pulled: number;
|
||||
failed: number;
|
||||
items: Array<{ policy_id: string; ok: boolean; error?: string | null; result?: unknown }>;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSchedulerWorkbenchRow extends Record<string, unknown> {
|
||||
schedule_id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
status: string;
|
||||
cron_expr?: string | null;
|
||||
next_run?: string | null;
|
||||
last_run?: string | null;
|
||||
last_status?: string | null;
|
||||
latest_run_error?: string | null;
|
||||
run_count?: number;
|
||||
}
|
||||
|
||||
export interface DigitalEmployeeSchedulerWorkbenchResponse {
|
||||
items: DigitalEmployeeSchedulerWorkbenchRow[];
|
||||
settings: {
|
||||
enabled: boolean;
|
||||
catch_up_on_startup: boolean;
|
||||
max_run_history: number;
|
||||
};
|
||||
source?: string;
|
||||
}
|
||||
|
||||
export type DigitalEmployeeBusinessViewKind = 'plans' | 'tasks' | 'runs' | 'events' | 'artifacts' | 'artifact-groups' | 'approvals';
|
||||
|
||||
export interface DigitalEmployeeBusinessViewQuery {
|
||||
entity_id?: string | null;
|
||||
@@ -352,6 +576,7 @@ export interface DigitalEmployeeBusinessViewRow extends Record<string, unknown>
|
||||
run_id?: string | null;
|
||||
event_id?: string | null;
|
||||
artifact_id?: string | null;
|
||||
artifact_ids?: string[] | null;
|
||||
approval_id?: string | null;
|
||||
title?: string | null;
|
||||
status?: string | null;
|
||||
@@ -367,6 +592,13 @@ export interface DigitalEmployeeBusinessViewRow extends Record<string, unknown>
|
||||
approval_conflict_resolution_event_id?: string | null;
|
||||
task_count?: number | null;
|
||||
run_count?: number | null;
|
||||
artifact_count?: number | null;
|
||||
device_count?: number | null;
|
||||
version_count?: number | null;
|
||||
latest_artifact_id?: string | null;
|
||||
retention_status_counts?: Record<string, number> | null;
|
||||
cleanup_status_counts?: Record<string, number> | null;
|
||||
version_key?: string | null;
|
||||
latest_event_at?: string | null;
|
||||
last_event_message?: string | null;
|
||||
occurred_at?: string | null;
|
||||
|
||||
Reference in New Issue
Block a user