feat(hands): restructure Hands UI with Chinese localization
Major changes: - Add HandList.tsx component for left sidebar - Add HandTaskPanel.tsx for middle content area - Restructure Sidebar tabs: 分身/HANDS/Workflow - Remove Hands tab from RightPanel - Localize all UI text to Chinese - Archive legacy OpenClaw documentation - Add Hands integration lessons document - Update feature checklist with new components UI improvements: - Left sidebar now shows Hands list with status icons - Middle area shows selected Hand's tasks and results - Consistent styling with Tailwind CSS - Chinese status labels and buttons Documentation: - Create docs/archive/openclaw-legacy/ for old docs - Add docs/knowledge-base/hands-integration-lessons.md - Update docs/knowledge-base/feature-checklist.md - Update docs/knowledge-base/README.md Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
419
desktop/src/components/ApprovalsPanel.tsx
Normal file
419
desktop/src/components/ApprovalsPanel.tsx
Normal file
@@ -0,0 +1,419 @@
|
||||
/**
|
||||
* ApprovalsPanel - OpenFang Execution Approvals UI
|
||||
*
|
||||
* Displays pending, approved, and rejected approval requests
|
||||
* for Hand executions that require human approval.
|
||||
*
|
||||
* Design based on OpenFang Dashboard v0.4.0
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
useGatewayStore,
|
||||
type Approval,
|
||||
type ApprovalStatus,
|
||||
} from '../store/gatewayStore';
|
||||
import {
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
AlertCircle,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
} 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, loadApprovals, respondToApproval, isLoading } =
|
||||
useGatewayStore();
|
||||
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;
|
||||
Reference in New Issue
Block a user