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
P0 安全性: - account/handlers.rs: .unwrap() → .expect() 语义化错误信息 - relay/handlers.rs: SSE Response .unwrap() → .expect() P1 编译质量 (6 warnings → 0): - kernel.rs: 移除未使用的 Capability import 和 config_clone 变量 - pipeline_commands.rs: 未使用变量 id → _id - db.rs: 移除多余括号 - relay/service.rs: 移除未使用的 StreamExt import - telemetry/service.rs: 抑制 param_idx 未读赋值警告 - main.rs: TcpKeepalive::with_retries() Linux-only 条件编译 P2 代码清理: - 移除 handStore/HandsPanel/HandTaskPanel/gateway-api/SchedulerPanel 调试 console.log - SchedulerPanel: 修复 updateWorkflow 未解构导致 TS 编译错误 - 文档清理 zclaw-channels 已移除 crate 的引用
642 lines
23 KiB
TypeScript
642 lines
23 KiB
TypeScript
/**
|
|
* HandsPanel - ZCLAW Hands Management UI
|
|
*
|
|
* Displays available ZCLAW Hands (autonomous capability packages)
|
|
* with detailed status, requirements, and activation controls.
|
|
*
|
|
* Design based on ZCLAW Dashboard v0.4.0
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
|
|
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react';
|
|
import { BrowserHandCard } from './BrowserHand';
|
|
import type { HandParameter } from '../types/hands';
|
|
import { HAND_DEFINITIONS } from '../types/hands';
|
|
import { HandParamsForm } from './HandParamsForm';
|
|
import { ApprovalsPanel } from './ApprovalsPanel';
|
|
import { useToast } from './ui/Toast';
|
|
|
|
// === Tab Type ===
|
|
type TabType = 'hands' | 'approvals';
|
|
|
|
// === Status Badge Component ===
|
|
|
|
type HandStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed';
|
|
|
|
// === Parameter Validation Helper ===
|
|
|
|
function validateAllParameters(
|
|
parameters: HandParameter[],
|
|
values: Record<string, unknown>
|
|
): Record<string, string> {
|
|
const errors: Record<string, string> = {};
|
|
parameters.forEach(param => {
|
|
if (param.required) {
|
|
const value = values[param.name];
|
|
if (value === undefined || value === null || value === '') {
|
|
errors[param.name] = `${param.label} is required`;
|
|
}
|
|
}
|
|
});
|
|
return errors;
|
|
}
|
|
|
|
interface StatusConfig {
|
|
label: string;
|
|
className: string;
|
|
dotClass: string;
|
|
}
|
|
|
|
const STATUS_CONFIG: Record<HandStatus, StatusConfig> = {
|
|
idle: {
|
|
label: '就绪',
|
|
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
|
|
dotClass: 'bg-green-500',
|
|
},
|
|
running: {
|
|
label: '运行中',
|
|
className: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
|
|
dotClass: 'bg-blue-500 animate-pulse',
|
|
},
|
|
needs_approval: {
|
|
label: '待审批',
|
|
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
|
|
dotClass: 'bg-yellow-500',
|
|
},
|
|
error: {
|
|
label: '错误',
|
|
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
|
dotClass: 'bg-red-500',
|
|
},
|
|
unavailable: {
|
|
label: '不可用',
|
|
className: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
|
|
dotClass: 'bg-gray-400',
|
|
},
|
|
setup_needed: {
|
|
label: '需配置',
|
|
className: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
|
|
dotClass: 'bg-orange-500',
|
|
},
|
|
};
|
|
|
|
function HandStatusBadge({ status }: { status: string }) {
|
|
const config = STATUS_CONFIG[status as HandStatus] || STATUS_CONFIG.unavailable;
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
|
|
{config.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// === Category Badge Component ===
|
|
|
|
const CATEGORY_CONFIG: Record<string, { label: string; className: string }> = {
|
|
productivity: { label: '生产力', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400' },
|
|
data: { label: '数据', className: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400' },
|
|
content: { label: '内容', className: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400' },
|
|
communication: { label: '通信', className: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400' },
|
|
};
|
|
|
|
function CategoryBadge({ category }: { category?: string }) {
|
|
if (!category) return null;
|
|
const config = CATEGORY_CONFIG[category] || { label: category, className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400' };
|
|
return (
|
|
<span className={`px-2 py-0.5 rounded text-xs ${config.className}`}>
|
|
{config.label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
// === Requirement Item Component ===
|
|
|
|
function RequirementItem({ requirement }: { requirement: HandRequirement }) {
|
|
return (
|
|
<div className={`flex items-start gap-2 text-sm py-1 ${requirement.met ? 'text-green-700 dark:text-green-400' : 'text-red-600 dark:text-red-400'}`}>
|
|
<span className="flex-shrink-0 mt-0.5">
|
|
{requirement.met ? (
|
|
<CheckCircle className="w-4 h-4" />
|
|
) : (
|
|
<XCircle className="w-4 h-4" />
|
|
)}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<span className="break-words">{requirement.description}</span>
|
|
{requirement.details && (
|
|
<span className="text-gray-400 dark:text-gray-500 text-xs ml-1">({requirement.details})</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Hand Details Modal Component ===
|
|
|
|
interface HandDetailsModalProps {
|
|
hand: Hand;
|
|
isOpen: boolean;
|
|
onClose: () => void;
|
|
onActivate: (params?: Record<string, unknown>) => void;
|
|
isActivating: boolean;
|
|
}
|
|
|
|
function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: HandDetailsModalProps) {
|
|
// Get Hand parameters from definitions
|
|
const handDefinition = HAND_DEFINITIONS.find(h => h.id === hand.id);
|
|
const parameters: HandParameter[] = handDefinition?.parameters || [];
|
|
|
|
// Form state for parameters
|
|
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
|
|
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
|
|
const [showParamsForm, setShowParamsForm] = useState(false);
|
|
|
|
// Initialize default values
|
|
useEffect(() => {
|
|
if (parameters.length > 0) {
|
|
const defaults: Record<string, unknown> = {};
|
|
parameters.forEach(p => {
|
|
if (p.defaultValue !== undefined) {
|
|
defaults[p.name] = p.defaultValue;
|
|
}
|
|
});
|
|
setParamValues(defaults);
|
|
}
|
|
}, [parameters]);
|
|
|
|
// Reset form when modal opens/closes
|
|
useEffect(() => {
|
|
if (isOpen) {
|
|
setShowParamsForm(false);
|
|
setParamErrors({});
|
|
}
|
|
}, [isOpen]);
|
|
|
|
const handleActivateClick = useCallback(() => {
|
|
if (parameters.length > 0 && !showParamsForm) {
|
|
// Show params form first
|
|
setShowParamsForm(true);
|
|
return;
|
|
}
|
|
|
|
// Validate parameters if showing form
|
|
if (showParamsForm) {
|
|
const errors = validateAllParameters(parameters, paramValues);
|
|
setParamErrors(errors);
|
|
if (Object.keys(errors).length > 0) {
|
|
return;
|
|
}
|
|
// Pass parameters to onActivate
|
|
onActivate(paramValues);
|
|
} else {
|
|
onActivate();
|
|
}
|
|
}, [parameters, showParamsForm, paramValues, onActivate]);
|
|
|
|
if (!isOpen) return null;
|
|
|
|
const canActivate = hand.status === 'idle' || hand.status === 'setup_needed';
|
|
const hasUnmetRequirements = hand.requirements?.some(r => !r.met);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
|
onClick={onClose}
|
|
/>
|
|
|
|
{/* Modal */}
|
|
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-2xl">{hand.icon || '🤖'}</span>
|
|
<div>
|
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">{hand.name}</h2>
|
|
<HandStatusBadge status={hand.status} />
|
|
</div>
|
|
</div>
|
|
<button
|
|
onClick={onClose}
|
|
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
|
|
>
|
|
<span className="text-xl">×</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Body */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{/* Description */}
|
|
<p className="text-sm text-gray-600 dark:text-gray-400">{hand.description}</p>
|
|
|
|
{/* Agent Config */}
|
|
{(hand.provider || hand.model) && (
|
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
代理配置
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-3 text-sm">
|
|
{hand.provider && (
|
|
<div>
|
|
<span className="text-gray-500 dark:text-gray-400">提供商</span>
|
|
<p className="font-medium text-gray-900 dark:text-white">{hand.provider}</p>
|
|
</div>
|
|
)}
|
|
{hand.model && (
|
|
<div>
|
|
<span className="text-gray-500 dark:text-gray-400">模型</span>
|
|
<p className="font-medium text-gray-900 dark:text-white">{hand.model}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Requirements */}
|
|
{hand.requirements && hand.requirements.length > 0 && (
|
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
环境要求
|
|
</h3>
|
|
<div className="space-y-1">
|
|
{hand.requirements.map((req, idx) => (
|
|
<RequirementItem key={idx} requirement={req} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Tools */}
|
|
{hand.tools && hand.tools.length > 0 && (
|
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
工具 ({hand.tools.length})
|
|
</h3>
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{hand.tools.map((tool, idx) => (
|
|
<span
|
|
key={idx}
|
|
className="px-2 py-0.5 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded text-xs text-gray-700 dark:text-gray-300 font-mono"
|
|
>
|
|
{tool}
|
|
</span>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Parameters Form (shown when activating) */}
|
|
{showParamsForm && parameters.length > 0 && (
|
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-3">
|
|
执行参数
|
|
</h3>
|
|
<HandParamsForm
|
|
parameters={parameters}
|
|
values={paramValues}
|
|
onChange={setParamValues}
|
|
errors={paramErrors}
|
|
disabled={isActivating}
|
|
presetKey={`hand-${hand.id}`}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* Dashboard Metrics */}
|
|
{hand.metrics && hand.metrics.length > 0 && (
|
|
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg p-3">
|
|
<h3 className="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">
|
|
仪表盘指标 ({hand.metrics.length})
|
|
</h3>
|
|
<div className="grid grid-cols-3 gap-2">
|
|
{hand.metrics.map((metric, idx) => (
|
|
<div
|
|
key={idx}
|
|
className="bg-white dark:bg-gray-800 rounded p-2 text-center border border-gray-200 dark:border-gray-700"
|
|
>
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 truncate">{metric}</div>
|
|
<div className="text-lg font-semibold text-gray-400 dark:text-gray-500">-</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={showParamsForm ? () => setShowParamsForm(false) : onClose}
|
|
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
|
|
>
|
|
{showParamsForm ? '返回' : '关闭'}
|
|
</button>
|
|
<button
|
|
onClick={handleActivateClick}
|
|
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
|
className="px-4 py-2 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-lg hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
|
>
|
|
{isActivating ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
激活中...
|
|
</>
|
|
) : hasUnmetRequirements ? (
|
|
<>
|
|
<Settings className="w-4 h-4" />
|
|
需要配置
|
|
</>
|
|
) : showParamsForm ? (
|
|
<>
|
|
<Play className="w-4 h-4" />
|
|
执行
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="w-4 h-4" />
|
|
激活
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Hand Card Component ===
|
|
|
|
interface HandCardProps {
|
|
hand: Hand;
|
|
onDetails: (hand: Hand) => void;
|
|
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
|
|
isActivating: boolean;
|
|
}
|
|
|
|
function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps) {
|
|
const canActivate = hand.status === 'idle';
|
|
const hasUnmetRequirements = hand.requirements_met === false;
|
|
|
|
return (
|
|
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4 shadow-sm hover:shadow-md transition-shadow">
|
|
{/* Header */}
|
|
<div className="flex items-start justify-between gap-3 mb-2">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<span className="text-xl flex-shrink-0">{hand.icon || '🤖'}</span>
|
|
<h3 className="font-medium text-gray-900 dark:text-white truncate">{hand.name}</h3>
|
|
</div>
|
|
<HandStatusBadge status={hand.status} />
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{hand.description}</p>
|
|
|
|
{/* Requirements Summary (if any unmet) */}
|
|
{hasUnmetRequirements && (
|
|
<div className="mb-3 p-2 bg-orange-50 dark:bg-orange-900/20 rounded border border-orange-200 dark:border-orange-800">
|
|
<div className="flex items-center gap-2 text-orange-700 dark:text-orange-400 text-xs font-medium">
|
|
<AlertTriangle className="w-3.5 h-3.5" />
|
|
<span>部分环境要求未满足</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Meta Info */}
|
|
<div className="flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 mb-3">
|
|
{hand.toolCount !== undefined && (
|
|
<span>{hand.toolCount} 个工具</span>
|
|
)}
|
|
{hand.metricCount !== undefined && (
|
|
<span>{hand.metricCount} 个指标</span>
|
|
)}
|
|
{hand.category && (
|
|
<CategoryBadge category={hand.category} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => onDetails(hand)}
|
|
className="px-3 py-1.5 text-sm border border-gray-200 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300 flex items-center gap-1"
|
|
>
|
|
详情
|
|
<ChevronRight className="w-3.5 h-3.5" />
|
|
</button>
|
|
<button
|
|
onClick={() => onActivate(hand)}
|
|
disabled={!canActivate || hasUnmetRequirements || isActivating}
|
|
className="px-3 py-1.5 text-sm bg-gray-700 dark:bg-gray-600 text-white rounded-md hover:bg-gray-800 dark:hover:bg-gray-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
|
|
>
|
|
{isActivating ? (
|
|
<>
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
激活中...
|
|
</>
|
|
) : hand.status === 'running' ? (
|
|
<>
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
运行中...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Zap className="w-3.5 h-3.5" />
|
|
激活
|
|
</>
|
|
)}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// === Main HandsPanel Component ===
|
|
|
|
export function HandsPanel() {
|
|
const { hands, loadHands, triggerHand, isLoading, error: storeError, getHandDetails } = useHandStore();
|
|
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
|
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [activeTab, setActiveTab] = useState<TabType>('hands');
|
|
const { toast } = useToast();
|
|
|
|
useEffect(() => {
|
|
loadHands();
|
|
}, [loadHands]);
|
|
|
|
const handleDetails = useCallback(async (hand: Hand) => {
|
|
// Load full details before showing modal
|
|
const details = await getHandDetails(hand.id);
|
|
setSelectedHand(details || hand);
|
|
setShowModal(true);
|
|
}, [getHandDetails]);
|
|
|
|
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
|
|
setActivatingHandId(hand.id);
|
|
|
|
try {
|
|
const result = await triggerHand(hand.id, params);
|
|
|
|
if (result) {
|
|
toast(`Hand "${hand.name}" 已成功激活`, 'success');
|
|
// Refresh hands after activation
|
|
await loadHands();
|
|
} else {
|
|
// Check if there's an error in the store
|
|
const errorMsg = storeError || '激活失败,请检查后端连接';
|
|
console.error(`[HandsPanel] Hand activation failed:`, errorMsg);
|
|
toast(`Hand "${hand.name}" 激活失败: ${errorMsg}`, 'error');
|
|
}
|
|
} catch (err) {
|
|
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
console.error(`[HandsPanel] Hand activation error:`, errorMsg);
|
|
toast(`Hand "${hand.name}" 激活异常: ${errorMsg}`, 'error');
|
|
} finally {
|
|
setActivatingHandId(null);
|
|
}
|
|
}, [triggerHand, loadHands, toast, storeError]);
|
|
|
|
const handleCloseModal = useCallback(() => {
|
|
setShowModal(false);
|
|
setSelectedHand(null);
|
|
}, []);
|
|
|
|
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
|
|
if (!selectedHand) return;
|
|
setShowModal(false);
|
|
await handleActivate(selectedHand, params);
|
|
}, [selectedHand, handleActivate]);
|
|
|
|
if (isLoading && hands.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">加载 Hands 中...</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (hands.length === 0) {
|
|
return (
|
|
<div className="p-4 text-center">
|
|
<div className="w-12 h-12 bg-gray-100 dark:bg-gray-800 rounded-full flex items-center justify-center mx-auto mb-3">
|
|
<Zap className="w-6 h-6 text-gray-400" />
|
|
</div>
|
|
<p className="text-sm text-gray-500 dark:text-gray-400 mb-3">暂无可用的 Hands</p>
|
|
<p className="text-xs text-gray-400 dark:text-gray-500">
|
|
请连接到 ZCLAW 以查看可用的自主能力包。
|
|
</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">
|
|
Hands
|
|
</h2>
|
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
|
自主能力包
|
|
</p>
|
|
</div>
|
|
<button
|
|
onClick={() => loadHands()}
|
|
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>
|
|
|
|
{/* Tabs */}
|
|
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
|
<button
|
|
onClick={() => setActiveTab('hands')}
|
|
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === 'hands'
|
|
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
|
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
<Zap className="w-4 h-4" />
|
|
能力包
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('approvals')}
|
|
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
activeTab === 'approvals'
|
|
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
|
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
|
}`}
|
|
>
|
|
<Clock className="w-4 h-4" />
|
|
审批历史
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tab Content */}
|
|
{activeTab === 'approvals' ? (
|
|
<ApprovalsPanel />
|
|
) : (
|
|
<>
|
|
{/* Stats */}
|
|
<div className="flex items-center gap-4 text-sm">
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
可用 <span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
|
|
</span>
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
就绪 <span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
|
|
</span>
|
|
</div>
|
|
|
|
{/* Hand Cards Grid */}
|
|
<div className="grid gap-3">
|
|
{hands.map((hand) => {
|
|
// Check if this is a Browser Hand
|
|
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
|
|
|
return isBrowserHand ? (
|
|
<BrowserHandCard
|
|
key={hand.id}
|
|
hand={hand}
|
|
/>
|
|
) : (
|
|
<HandCard
|
|
key={hand.id}
|
|
hand={hand}
|
|
onDetails={handleDetails}
|
|
onActivate={handleActivate}
|
|
isActivating={activatingHandId === hand.id}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Details Modal */}
|
|
{selectedHand && (
|
|
<HandDetailsModal
|
|
hand={selectedHand}
|
|
isOpen={showModal}
|
|
onClose={handleCloseModal}
|
|
onActivate={handleModalActivate}
|
|
isActivating={activatingHandId === selectedHand.id}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default HandsPanel;
|