Files
zclaw_openfang/desktop/src/components/ApprovalsPanel.tsx
iven 0d4fa96b82
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
refactor: 统一项目名称从OpenFang到ZCLAW
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括:
- 配置文件中的项目名称
- 代码注释和文档引用
- 环境变量和路径
- 类型定义和接口名称
- 测试用例和模拟数据

同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
2026-03-27 07:36:03 +08:00

418 lines
12 KiB
TypeScript

/**
* ApprovalsPanel - ZCLAW Execution Approvals UI
*
* Displays pending, approved, and rejected approval requests
* for Hand executions that require human approval.
*
* Design based on ZCLAW Dashboard v0.4.0
*/
import { useState, useEffect, useCallback } from 'react';
import { useHandStore } from '../store/handStore';
import type { Approval, ApprovalStatus } from '../store/handStore';
import {
CheckCircle,
XCircle,
Clock,
RefreshCw,
AlertCircle,
Loader2,
} from 'lucide-react';
// === Status Badge Component ===
type FilterStatus = 'all' | ApprovalStatus;
interface StatusFilterConfig {
label: string;
className: string;
}
const STATUS_FILTER_CONFIG: Record<FilterStatus, StatusFilterConfig> = {
all: {
label: '全部',
className:
'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
},
pending: {
label: '待审批',
className:
'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
},
approved: {
label: '已批准',
className:
'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
},
rejected: {
label: '已拒绝',
className:
'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
},
expired: {
label: '已过期',
className:
'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
},
};
function StatusFilterButton({
status,
isActive,
count,
onClick,
}: {
status: FilterStatus;
isActive: boolean;
count?: number;
onClick: () => void;
}) {
const config = STATUS_FILTER_CONFIG[status];
return (
<button
onClick={onClick}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${
isActive
? config.className
: 'text-gray-500 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'
}`}
>
{config.label}
{count !== undefined && count > 0 && (
<span className="ml-1.5 text-xs opacity-75">({count})</span>
)}
</button>
);
}
// === Approval Status Icon ===
function ApprovalStatusIcon({ status }: { status: ApprovalStatus }) {
switch (status) {
case 'pending':
return <Clock className="w-4 h-4 text-yellow-500" />;
case 'approved':
return <CheckCircle className="w-4 h-4 text-green-500" />;
case 'rejected':
return <XCircle className="w-4 h-4 text-red-500" />;
case 'expired':
return <AlertCircle className="w-4 h-4 text-gray-400" />;
default:
return null;
}
}
// === Approval Card Component ===
interface ApprovalCardProps {
approval: Approval;
onApprove: (id: string) => void;
onReject: (id: string, reason: string) => void;
isProcessing: boolean;
}
function ApprovalCard({
approval,
onApprove,
onReject,
isProcessing,
}: ApprovalCardProps) {
const [showRejectInput, setShowRejectInput] = useState(false);
const [rejectReason, setRejectReason] = useState('');
const isPending = approval.status === 'pending';
const handleReject = () => {
if (showRejectInput && rejectReason.trim()) {
onReject(approval.id, rejectReason.trim());
setRejectReason('');
setShowRejectInput(false);
} else {
setShowRejectInput(true);
}
};
const handleCancelReject = () => {
setShowRejectInput(false);
setRejectReason('');
};
return (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm">
{/* Header */}
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-2 min-w-0">
<ApprovalStatusIcon status={approval.status} />
<div className="min-w-0">
<h3 className="font-medium text-gray-900 dark:text-white truncate">
{approval.handName}
</h3>
<p className="text-xs text-gray-500 dark:text-gray-400">
{approval.action || '执行'} {' '}
{new Date(approval.requestedAt).toLocaleString()}
</p>
</div>
</div>
<span
className={`flex-shrink-0 px-2 py-0.5 rounded text-xs font-medium ${
STATUS_FILTER_CONFIG[approval.status]?.className ||
STATUS_FILTER_CONFIG.pending.className
}`}
>
{approval.status}
</span>
</div>
{/* Reason */}
{approval.reason && (
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3">
{approval.reason}
</p>
)}
{/* Params Preview */}
{approval.params && Object.keys(approval.params).length > 0 && (
<div className="mb-3 p-2 bg-gray-50 dark:bg-gray-900 rounded text-xs font-mono text-gray-600 dark:text-gray-400 overflow-x-auto">
<pre>{JSON.stringify(approval.params, null, 2)}</pre>
</div>
)}
{/* Response Info (if responded) */}
{approval.status !== 'pending' && approval.respondedAt && (
<div className="mb-3 text-xs text-gray-500 dark:text-gray-400">
<p>
: {new Date(approval.respondedAt).toLocaleString()}
{approval.respondedBy && `${approval.respondedBy}`}
</p>
{approval.responseReason && (
<p className="mt-1 italic">"{approval.responseReason}"</p>
)}
</div>
)}
{/* Reject Input */}
{showRejectInput && (
<div className="mb-3 space-y-2">
<textarea
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
placeholder="请输入拒绝原因..."
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-md bg-white dark:bg-gray-900 text-gray-900 dark:text-white focus:outline-none focus:ring-2 focus:ring-red-500"
rows={2}
autoFocus
/>
<div className="flex gap-2">
<button
onClick={handleCancelReject}
className="px-3 py-1 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200"
>
</button>
<button
onClick={handleReject}
disabled={!rejectReason.trim() || isProcessing}
className="px-3 py-1 text-sm bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
)}
{/* Actions */}
{isPending && !showRejectInput && (
<div className="flex items-center gap-2">
<button
onClick={() => onApprove(approval.id)}
disabled={isProcessing}
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isProcessing ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
</button>
<button
onClick={handleReject}
disabled={isProcessing}
className="px-3 py-1.5 text-sm border border-red-200 dark:border-red-800 text-red-600 dark:text-red-400 rounded-md hover:bg-red-50 dark:hover:bg-red-900/20 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
<XCircle className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
);
}
// === Empty State Component ===
function EmptyState({ filter }: { filter: FilterStatus }) {
const messages: Record<FilterStatus, { title: string; description: string }> = {
all: {
title: '暂无审批请求',
description:
'当代理请求执行敏感操作时,审批请求将显示在这里。',
},
pending: {
title: '暂无待审批请求',
description: '所有审批请求已处理完成。',
},
approved: {
title: '暂无已批准请求',
description: '还没有批准任何请求。',
},
rejected: {
title: '暂无已拒绝请求',
description: '还没有拒绝任何请求。',
},
expired: {
title: '暂无已过期请求',
description: '没有过期的审批请求。',
},
};
const { title, description } = messages[filter];
return (
<div className="text-center py-12">
<div className="w-16 h-16 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-4">
<AlertCircle className="w-8 h-8 text-gray-400" />
</div>
<p className="text-sm font-medium text-gray-900 dark:text-white mb-1">
{title}
</p>
<p className="text-xs text-gray-500 dark:text-gray-400 max-w-sm mx-auto">
{description}
</p>
</div>
);
}
// === Main ApprovalsPanel Component ===
export function ApprovalsPanel() {
const approvals = useHandStore((s) => s.approvals);
const loadApprovals = useHandStore((s) => s.loadApprovals);
const respondToApproval = useHandStore((s) => s.respondToApproval);
const isLoading = useHandStore((s) => s.isLoading);
const [filter, setFilter] = useState<FilterStatus>('all');
const [processingId, setProcessingId] = useState<string | null>(null);
useEffect(() => {
loadApprovals();
}, [loadApprovals]);
const handleApprove = useCallback(
async (id: string) => {
setProcessingId(id);
try {
await respondToApproval(id, true);
} finally {
setProcessingId(null);
}
},
[respondToApproval]
);
const handleReject = useCallback(
async (id: string, reason: string) => {
setProcessingId(id);
try {
await respondToApproval(id, false, reason);
} finally {
setProcessingId(null);
}
},
[respondToApproval]
);
// Filter approvals
const filteredApprovals =
filter === 'all'
? approvals
: approvals.filter((a) => a.status === filter);
// Count by status
const counts = {
all: approvals.length,
pending: approvals.filter((a) => a.status === 'pending').length,
approved: approvals.filter((a) => a.status === 'approved').length,
rejected: approvals.filter((a) => a.status === 'rejected').length,
expired: approvals.filter((a) => a.status === 'expired').length,
};
if (isLoading && approvals.length === 0) {
return (
<div className="p-4 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
...
</p>
</div>
);
}
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
Hand
</p>
</div>
<button
onClick={() => loadApprovals()}
disabled={isLoading}
className="text-sm text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1 disabled:opacity-50"
>
{isLoading ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<RefreshCw className="w-3.5 h-3.5" />
)}
</button>
</div>
{/* Filters */}
<div className="flex items-center gap-1 p-1 bg-gray-100 dark:bg-gray-800 rounded-lg">
{(Object.keys(STATUS_FILTER_CONFIG) as FilterStatus[]).map((status) => (
<StatusFilterButton
key={status}
status={status}
isActive={filter === status}
count={counts[status]}
onClick={() => setFilter(status)}
/>
))}
</div>
{/* Approvals List */}
{filteredApprovals.length === 0 ? (
<EmptyState filter={filter} />
) : (
<div className="space-y-3">
{filteredApprovals.map((approval) => (
<ApprovalCard
key={approval.id}
approval={approval}
onApprove={handleApprove}
onReject={handleReject}
isProcessing={processingId === approval.id}
/>
))}
</div>
)}
</div>
);
}
export default ApprovalsPanel;