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
重构所有代码和文档中的项目名称,将OpenFang统一更新为ZCLAW。包括: - 配置文件中的项目名称 - 代码注释和文档引用 - 环境变量和路径 - 类型定义和接口名称 - 测试用例和模拟数据 同时优化部分代码结构,移除未使用的模块,并更新相关依赖项。
418 lines
12 KiB
TypeScript
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;
|