feat(automation): implement unified automation system with Hands and Workflows

Phase 1 - Core Fixes:
- Fix parameter passing in HandsPanel (params now passed to triggerHand)
- Migrate HandsPanel from useGatewayStore to useHandStore
- Add type adapters and category mapping for 7 Hands
- Create useAutomationEvents hook for WebSocket event handling

Phase 2 - UI Components:
- Create AutomationPanel as unified entry point
- Create AutomationCard with grid/list view support
- Create AutomationFilters with category tabs and search
- Create BatchActionBar for batch operations

Phase 3 - Advanced Features:
- Create ScheduleEditor with visual scheduling (no cron syntax)
- Support frequency: once, daily, weekly, monthly, custom
- Add timezone selection and end date options

Technical Details:
- AutomationItem type unifies Hand and Workflow
- CategoryType: research, data, automation, communication, content, productivity
- ScheduleInfo interface for scheduling configuration
- WebSocket events: hand, workflow, approval

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-18 16:32:18 +08:00
parent dfeb286591
commit 3a7631e035
11 changed files with 2321 additions and 40 deletions

View File

@@ -0,0 +1,402 @@
/**
* AutomationCard - Unified Card for Hands and Workflows
*
* Displays automation items with status, parameters, and actions.
* Supports both grid and list view modes.
*
* @module components/Automation/AutomationCard
*/
import { useState, useCallback } from 'react';
import type { AutomationItem, AutomationStatus } from '../../types/automation';
import { CATEGORY_CONFIGS } from '../../types/automation';
import type { HandParameter } from '../../types/hands';
import { HandParamsForm } from '../HandParamsForm';
import {
Zap,
Clock,
CheckCircle,
XCircle,
AlertTriangle,
Loader2,
Settings,
Play,
MoreVertical,
} from 'lucide-react';
// === Status Config ===
const STATUS_CONFIG: Record<AutomationStatus, {
label: string;
className: string;
dotClass: string;
icon?: typeof CheckCircle;
}> = {
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',
icon: Loader2,
},
needs_approval: {
label: '待审批',
className: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
dotClass: 'bg-yellow-500',
icon: AlertTriangle,
},
error: {
label: '错误',
className: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
dotClass: 'bg-red-500',
icon: XCircle,
},
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',
icon: Settings,
},
completed: {
label: '已完成',
className: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
dotClass: 'bg-green-500',
icon: CheckCircle,
},
paused: {
label: '已暂停',
className: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
dotClass: 'bg-gray-400',
},
};
// === Component Props ===
interface AutomationCardProps {
item: AutomationItem;
viewMode?: 'grid' | 'list';
isSelected?: boolean;
isExecuting?: boolean;
onSelect?: (selected: boolean) => void;
onExecute?: (params?: Record<string, unknown>) => void;
onClick?: () => void;
}
// === Status Badge Component ===
function StatusBadge({ status }: { status: AutomationStatus }) {
const config = STATUS_CONFIG[status] || STATUS_CONFIG.unavailable;
const Icon = config.icon;
return (
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${config.className}`}>
{Icon ? (
<Icon className={`w-3 h-3 ${status === 'running' ? 'animate-spin' : ''}`} />
) : (
<span className={`w-1.5 h-1.5 rounded-full ${config.dotClass}`} />
)}
{config.label}
</span>
);
}
// === Type Badge Component ===
function TypeBadge({ type }: { type: 'hand' | 'workflow' }) {
const isHand = type === 'hand';
return (
<span className={`px-2 py-0.5 rounded text-xs ${
isHand
? 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400'
: 'bg-cyan-100 text-cyan-700 dark:bg-cyan-900/30 dark:text-cyan-400'
}`}>
{isHand ? 'Hand' : '工作流'}
</span>
);
}
// === Category Badge Component ===
function CategoryBadge({ category }: { category: string }) {
const config = CATEGORY_CONFIGS[category as keyof typeof CATEGORY_CONFIGS];
if (!config) return null;
return (
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400">
{config.label}
</span>
);
}
// === Main Component ===
export function AutomationCard({
item,
viewMode = 'grid',
isSelected = false,
isExecuting = false,
onSelect,
onExecute,
onClick,
}: AutomationCardProps) {
const [showParams, setShowParams] = useState(false);
const [paramValues, setParamValues] = useState<Record<string, unknown>>({});
const [paramErrors, setParamErrors] = useState<Record<string, string>>({});
const hasParameters = item.parameters && item.parameters.length > 0;
const canActivate = item.status === 'idle' || item.status === 'setup_needed';
// Initialize default parameter values
const initializeDefaults = useCallback(() => {
if (item.parameters) {
const defaults: Record<string, unknown> = {};
item.parameters.forEach(p => {
if (p.defaultValue !== undefined) {
defaults[p.name] = p.defaultValue;
}
});
setParamValues(defaults);
}
}, [item.parameters]);
// Handle execute click
const handleExecuteClick = useCallback(() => {
if (hasParameters && !showParams) {
initializeDefaults();
setShowParams(true);
return;
}
// Validate parameters
if (showParams && item.parameters) {
const errors: Record<string, string> = {};
item.parameters.forEach(param => {
if (param.required) {
const value = paramValues[param.name];
if (value === undefined || value === null || value === '') {
errors[param.name] = `${param.label} is required`;
}
}
});
if (Object.keys(errors).length > 0) {
setParamErrors(errors);
return;
}
}
onExecute?.(showParams ? paramValues : undefined);
setShowParams(false);
setParamErrors({});
}, [hasParameters, showParams, initializeDefaults, item.parameters, paramValues, onExecute]);
// Handle checkbox change
const handleCheckboxChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
e.stopPropagation();
onSelect?.(e.target.checked);
}, [onSelect]);
// Get icon for item
const getItemIcon = () => {
if (item.icon) {
// Map string icon names to components
const iconMap: Record<string, string> = {
Video: '🎬',
UserPlus: '👤',
Database: '🗄️',
TrendingUp: '📈',
Search: '🔍',
Twitter: '🐦',
Globe: '🌐',
Zap: '⚡',
};
return iconMap[item.icon] || '🤖';
}
return item.type === 'hand' ? '🤖' : '📋';
};
if (viewMode === 'list') {
return (
<div
className={`flex items-center gap-3 p-3 bg-white dark:bg-gray-800 rounded-lg border transition-all cursor-pointer ${
isSelected
? 'border-orange-500 ring-1 ring-orange-500'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={onClick}
>
{/* Checkbox */}
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
onClick={e => e.stopPropagation()}
/>
{/* Icon */}
<span className="text-xl flex-shrink-0">{getItemIcon()}</span>
{/* Info */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
<TypeBadge type={item.type} />
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 truncate">{item.description}</p>
</div>
{/* Status */}
<StatusBadge status={item.status} />
{/* Actions */}
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation();
handleExecuteClick();
}}
disabled={!canActivate || isExecuting}
className="px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-1"
>
{isExecuting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
</>
) : (
<>
<Play className="w-3.5 h-3.5" />
</>
)}
</button>
<button
onClick={e => e.stopPropagation()}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
</div>
);
}
// Grid view
return (
<div
className={`relative bg-white dark:bg-gray-800 rounded-lg border transition-all ${
isSelected
? 'border-orange-500 ring-1 ring-orange-500'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
onClick={onClick}
>
{/* Selection checkbox */}
<div className="absolute top-2 left-2">
<input
type="checkbox"
checked={isSelected}
onChange={handleCheckboxChange}
className="w-4 h-4 rounded border-gray-300 text-orange-500 focus:ring-orange-500"
onClick={e => e.stopPropagation()}
/>
</div>
{/* Content */}
<div className="p-4 pt-8">
{/* 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">{getItemIcon()}</span>
<h3 className="font-medium text-gray-900 dark:text-white truncate">{item.name}</h3>
</div>
<StatusBadge status={item.status} />
</div>
{/* Description */}
<p className="text-sm text-gray-600 dark:text-gray-400 mb-3 line-clamp-2">{item.description}</p>
{/* Meta */}
<div className="flex items-center gap-2 mb-3">
<TypeBadge type={item.type} />
<CategoryBadge category={item.category} />
{item.schedule?.enabled && (
<span className="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
<Clock className="w-3 h-3" />
</span>
)}
</div>
{/* Parameters Form (shown when activating) */}
{showParams && item.parameters && item.parameters.length > 0 && (
<div className="mb-3 p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<HandParamsForm
parameters={item.parameters as HandParameter[]}
values={paramValues}
onChange={setParamValues}
errors={paramErrors}
disabled={isExecuting}
presetKey={`${item.type}-${item.id}`}
/>
</div>
)}
{/* Actions */}
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
handleExecuteClick();
}}
disabled={!canActivate || isExecuting}
className="flex-1 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-1"
>
{isExecuting ? (
<>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
...
</>
) : showParams ? (
<>
<Play className="w-3.5 h-3.5" />
</>
) : (
<>
<Zap className="w-3.5 h-3.5" />
</>
)}
</button>
<button
onClick={e => e.stopPropagation()}
className="p-1.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
title="更多选项"
>
<MoreVertical className="w-4 h-4" />
</button>
</div>
{/* Schedule indicator */}
{item.schedule?.nextRun && (
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
: {new Date(item.schedule.nextRun).toLocaleString('zh-CN')}
</div>
)}
</div>
</div>
);
}
export default AutomationCard;

View File

@@ -0,0 +1,204 @@
/**
* AutomationFilters - Category and Search Filters
*
* Provides category tabs, search input, and view mode toggle
* for the automation panel.
*
* @module components/Automation/AutomationFilters
*/
import { useState, useCallback } from 'react';
import type { CategoryType, CategoryStats } from '../../types/automation';
import { CATEGORY_CONFIGS } from '../../types/automation';
import {
Search,
Grid,
List,
Layers,
Database,
MessageSquare,
Video,
TrendingUp,
Zap,
ChevronDown,
} from 'lucide-react';
// === Icon Map ===
const CATEGORY_ICONS: Record<CategoryType, typeof Layers> = {
all: Layers,
research: Search,
data: Database,
automation: Zap,
communication: MessageSquare,
content: Video,
productivity: TrendingUp,
};
// === Component Props ===
interface AutomationFiltersProps {
selectedCategory: CategoryType;
onCategoryChange: (category: CategoryType) => void;
searchQuery: string;
onSearchChange: (query: string) => void;
viewMode: 'grid' | 'list';
onViewModeChange: (mode: 'grid' | 'list') => void;
categoryStats: CategoryStats;
}
// === Main Component ===
export function AutomationFilters({
selectedCategory,
onCategoryChange,
searchQuery,
onSearchChange,
viewMode,
onViewModeChange,
categoryStats,
}: AutomationFiltersProps) {
const [showCategoryDropdown, setShowCategoryDropdown] = useState(false);
// Handle search input
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
onSearchChange(e.target.value);
}, [onSearchChange]);
// Handle category click
const handleCategoryClick = useCallback((category: CategoryType) => {
onCategoryChange(category);
setShowCategoryDropdown(false);
}, [onCategoryChange]);
// Get categories with counts
const categories = Object.entries(CATEGORY_CONFIGS).map(([key, config]) => ({
...config,
count: categoryStats[key as CategoryType] || 0,
}));
// Selected category config
const selectedConfig = CATEGORY_CONFIGS[selectedCategory];
return (
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 space-y-3">
{/* Search and View Mode Row */}
<div className="flex items-center gap-3">
{/* Search Input */}
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="搜索 Hands 或工作流..."
value={searchQuery}
onChange={handleSearchChange}
className="w-full pl-10 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent"
/>
</div>
{/* View Mode Toggle */}
<div className="flex items-center border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
<button
onClick={() => onViewModeChange('grid')}
className={`p-2 ${
viewMode === 'grid'
? 'bg-orange-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="网格视图"
>
<Grid className="w-4 h-4" />
</button>
<button
onClick={() => onViewModeChange('list')}
className={`p-2 ${
viewMode === 'list'
? 'bg-orange-500 text-white'
: 'bg-white dark:bg-gray-800 text-gray-600 dark:text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
title="列表视图"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
{/* Category Tabs (Desktop) */}
<div className="hidden md:flex items-center gap-1 overflow-x-auto pb-1">
{categories.map(({ id, label, count }) => {
const Icon = CATEGORY_ICONS[id];
const isSelected = selectedCategory === id;
return (
<button
key={id}
onClick={() => onCategoryChange(id)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-full whitespace-nowrap transition-colors ${
isSelected
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
<Icon className="w-3.5 h-3.5" />
{label}
{count > 0 && (
<span className={`text-xs ${isSelected ? 'text-white/80' : 'text-gray-500 dark:text-gray-400'}`}>
({count})
</span>
)}
</button>
);
})}
</div>
{/* Category Dropdown (Mobile) */}
<div className="md:hidden relative">
<button
onClick={() => setShowCategoryDropdown(!showCategoryDropdown)}
className="w-full flex items-center justify-between px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
<div className="flex items-center gap-2">
{(() => {
const Icon = CATEGORY_ICONS[selectedCategory];
return <Icon className="w-4 h-4" />;
})()}
<span>{selectedConfig.label}</span>
<span className="text-gray-500 dark:text-gray-400">
({categoryStats[selectedCategory] || 0})
</span>
</div>
<ChevronDown className={`w-4 h-4 transition-transform ${showCategoryDropdown ? 'rotate-180' : ''}`} />
</button>
{showCategoryDropdown && (
<div className="absolute top-full left-0 right-0 mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg z-10 max-h-64 overflow-y-auto">
{categories.map(({ id, label, count }) => {
const Icon = CATEGORY_ICONS[id];
const isSelected = selectedCategory === id;
return (
<button
key={id}
onClick={() => handleCategoryClick(id)}
className={`w-full flex items-center justify-between px-3 py-2 text-sm ${
isSelected
? 'bg-orange-50 dark:bg-orange-900/20 text-orange-600 dark:text-orange-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
<div className="flex items-center gap-2">
<Icon className="w-4 h-4" />
<span>{label}</span>
</div>
<span className="text-gray-500 dark:text-gray-400">{count}</span>
</button>
);
})}
</div>
)}
</div>
</div>
);
}
export default AutomationFilters;

View File

@@ -0,0 +1,278 @@
/**
* AutomationPanel - Unified Automation Entry Point
*
* Combines Hands and Workflows into a single unified view,
* with category filtering, batch operations, and scheduling.
*
* @module components/Automation/AutomationPanel
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useHandStore } from '../../store/handStore';
import { useWorkflowStore } from '../../store/workflowStore';
import {
type AutomationItem,
type CategoryType,
type CategoryStats,
adaptToAutomationItems,
calculateCategoryStats,
filterByCategory,
searchAutomationItems,
} from '../../types/automation';
import { AutomationCard } from './AutomationCard';
import { AutomationFilters } from './AutomationFilters';
import { BatchActionBar } from './BatchActionBar';
import {
Zap,
RefreshCw,
Plus,
Calendar,
Search,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
// === View Mode ===
type ViewMode = 'grid' | 'list';
// === Component Props ===
interface AutomationPanelProps {
initialCategory?: CategoryType;
onSelect?: (item: AutomationItem) => void;
showBatchActions?: boolean;
}
// === Main Component ===
export function AutomationPanel({
initialCategory = 'all',
onSelect,
showBatchActions = true,
}: AutomationPanelProps) {
// Store state
const hands = useHandStore(s => s.hands);
const workflows = useWorkflowStore(s => s.workflows);
const isLoadingHands = useHandStore(s => s.isLoading);
const isLoadingWorkflows = useWorkflowStore(s => s.isLoading);
const loadHands = useHandStore(s => s.loadHands);
const loadWorkflows = useWorkflowStore(s => s.loadWorkflows);
const triggerHand = useHandStore(s => s.triggerHand);
const triggerWorkflow = useWorkflowStore(s => s.triggerWorkflow);
// UI state
const [selectedCategory, setSelectedCategory] = useState<CategoryType>(initialCategory);
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<ViewMode>('grid');
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [executingIds, setExecutingIds] = useState<Set<string>>(new Set());
const { toast } = useToast();
// Load data on mount
useEffect(() => {
loadHands();
loadWorkflows();
}, [loadHands, loadWorkflows]);
// Adapt hands and workflows to automation items
const automationItems = useMemo<AutomationItem[]>(() => {
return adaptToAutomationItems(hands, workflows);
}, [hands, workflows]);
// Calculate category stats
const categoryStats = useMemo<CategoryStats>(() => {
return calculateCategoryStats(automationItems);
}, [automationItems]);
// Filter and search items
const filteredItems = useMemo<AutomationItem[]>(() => {
let items = filterByCategory(automationItems, selectedCategory);
if (searchQuery.trim()) {
items = searchAutomationItems(items, searchQuery);
}
return items;
}, [automationItems, selectedCategory, searchQuery]);
// Selection handlers
const handleSelect = useCallback((id: string, selected: boolean) => {
setSelectedIds(prev => {
const next = new Set(prev);
if (selected) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
}, []);
const handleSelectAll = useCallback(() => {
setSelectedIds(new Set(filteredItems.map(item => item.id)));
}, [filteredItems]);
const handleDeselectAll = useCallback(() => {
setSelectedIds(new Set());
}, []);
// Execute handler
const handleExecute = useCallback(async (item: AutomationItem, params?: Record<string, unknown>) => {
setExecutingIds(prev => new Set(prev).add(item.id));
try {
if (item.type === 'hand') {
await triggerHand(item.id, params);
} else {
await triggerWorkflow(item.id, params);
}
toast(`${item.name} 执行成功`, 'success');
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
toast(`${item.name} 执行失败: ${errorMsg}`, 'error');
} finally {
setExecutingIds(prev => {
const next = new Set(prev);
next.delete(item.id);
return next;
});
}
}, [triggerHand, triggerWorkflow, toast]);
// Batch execute
const handleBatchExecute = useCallback(async () => {
const itemsToExecute = filteredItems.filter(item => selectedIds.has(item.id));
let successCount = 0;
let failCount = 0;
for (const item of itemsToExecute) {
try {
if (item.type === 'hand') {
await triggerHand(item.id);
} else {
await triggerWorkflow(item.id);
}
successCount++;
} catch {
failCount++;
}
}
if (successCount > 0) {
toast(`成功执行 ${successCount} 个项目`, 'success');
}
if (failCount > 0) {
toast(`${failCount} 个项目执行失败`, 'error');
}
setSelectedIds(new Set());
}, [filteredItems, selectedIds, triggerHand, triggerWorkflow, toast]);
// Refresh handler
const handleRefresh = useCallback(async () => {
await Promise.all([loadHands(), loadWorkflows()]);
toast('数据已刷新', 'success');
}, [loadHands, loadWorkflows, toast]);
const isLoading = isLoadingHands || isLoadingWorkflows;
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Zap className="w-5 h-5 text-orange-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
({automationItems.length})
</span>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleRefresh}
disabled={isLoading}
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 disabled:opacity-50"
title="刷新"
>
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="新建工作流"
>
<Plus className="w-4 h-4" />
</button>
<button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300"
title="调度管理"
>
<Calendar className="w-4 h-4" />
</button>
</div>
</div>
{/* Filters */}
<AutomationFilters
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
viewMode={viewMode}
onViewModeChange={setViewMode}
categoryStats={categoryStats}
/>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading && automationItems.length === 0 ? (
<div className="flex items-center justify-center h-32">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-32 text-center">
<Search className="w-8 h-8 text-gray-400 mb-2" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{searchQuery ? '没有找到匹配的项目' : '暂无自动化项目'}
</p>
</div>
) : (
<div className={
viewMode === 'grid'
? 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4'
: 'flex flex-col gap-2'
}>
{filteredItems.map(item => (
<AutomationCard
key={item.id}
item={item}
viewMode={viewMode}
isSelected={selectedIds.has(item.id)}
isExecuting={executingIds.has(item.id)}
onSelect={(selected) => handleSelect(item.id, selected)}
onExecute={(params) => handleExecute(item, params)}
onClick={() => onSelect?.(item)}
/>
))}
</div>
)}
</div>
{/* Batch Actions */}
{showBatchActions && selectedIds.size > 0 && (
<BatchActionBar
selectedCount={selectedIds.size}
totalCount={filteredItems.length}
onSelectAll={handleSelectAll}
onDeselectAll={handleDeselectAll}
onBatchExecute={handleBatchExecute}
onBatchSchedule={() => {
toast('批量调度功能开发中', 'info');
}}
/>
)}
</div>
);
}
export default AutomationPanel;

View File

@@ -0,0 +1,216 @@
/**
* BatchActionBar - Batch Operations Action Bar
*
* Provides batch action buttons for selected automation items.
* Supports batch execute, approve, reject, and schedule.
*
* @module components/Automation/BatchActionBar
*/
import { useState, useCallback } from 'react';
import {
Play,
Check,
X,
Clock,
XCircle,
MoreHorizontal,
Trash2,
Copy,
} from 'lucide-react';
// === Component Props ===
interface BatchActionBarProps {
selectedCount: number;
totalCount?: number; // Optional - for "select all X items" display
onSelectAll: () => void;
onDeselectAll: () => void;
onBatchExecute: () => Promise<void>;
onBatchApprove?: () => Promise<void>;
onBatchReject?: () => Promise<void>;
onBatchSchedule?: () => void;
onBatchDelete?: () => Promise<void>;
onBatchDuplicate?: () => Promise<void>;
}
// === Main Component ===
export function BatchActionBar({
selectedCount,
totalCount: _totalCount, // Used for future "select all X items" display
onSelectAll,
onDeselectAll,
onBatchExecute,
onBatchApprove,
onBatchReject,
onBatchSchedule,
onBatchDelete,
onBatchDuplicate,
}: BatchActionBarProps) {
const [isExecuting, setIsExecuting] = useState(false);
const [showMoreMenu, setShowMoreMenu] = useState(false);
// Handle batch execute
const handleExecute = useCallback(async () => {
setIsExecuting(true);
try {
await onBatchExecute();
} finally {
setIsExecuting(false);
}
}, [onBatchExecute]);
// Handle batch approve
const handleApprove = useCallback(async () => {
if (onBatchApprove) {
setIsExecuting(true);
try {
await onBatchApprove();
} finally {
setIsExecuting(false);
}
}
}, [onBatchApprove]);
// Handle batch reject
const handleReject = useCallback(async () => {
if (onBatchReject) {
setIsExecuting(true);
try {
await onBatchReject();
} finally {
setIsExecuting(false);
}
}
}, [onBatchReject]);
return (
<div className="sticky bottom-0 left-0 right-0 bg-orange-50 dark:bg-orange-900/20 border-t border-orange-200 dark:border-orange-800 px-4 py-3">
<div className="flex items-center justify-between gap-4">
{/* Selection Info */}
<div className="flex items-center gap-3">
<span className="text-sm text-orange-700 dark:text-orange-300">
<span className="font-medium">{selectedCount}</span>
</span>
<div className="flex items-center gap-1">
<button
onClick={onSelectAll}
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
>
</button>
<span className="text-orange-400 dark:text-orange-600">|</span>
<button
onClick={onDeselectAll}
className="text-xs text-orange-600 dark:text-orange-400 hover:underline"
>
</button>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Execute */}
<button
onClick={handleExecute}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-orange-500 text-white rounded-md hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Play className="w-3.5 h-3.5" />
</button>
{/* Approve (if handler provided) */}
{onBatchApprove && (
<button
onClick={handleApprove}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-green-500 text-white rounded-md hover:bg-green-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Check className="w-3.5 h-3.5" />
</button>
)}
{/* Reject (if handler provided) */}
{onBatchReject && (
<button
onClick={handleReject}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm bg-red-500 text-white rounded-md hover:bg-red-600 disabled:opacity-50 disabled:cursor-not-allowed"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{/* Schedule */}
{onBatchSchedule && (
<button
onClick={onBatchSchedule}
disabled={isExecuting}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm border border-orange-300 dark:border-orange-700 text-orange-600 dark:text-orange-400 rounded-md hover:bg-orange-100 dark:hover:bg-orange-900/30 disabled:opacity-50 disabled:cursor-not-allowed"
>
<Clock className="w-3.5 h-3.5" />
</button>
)}
{/* More Actions */}
{(onBatchDelete || onBatchDuplicate) && (
<div className="relative">
<button
onClick={() => setShowMoreMenu(!showMoreMenu)}
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
>
<MoreHorizontal className="w-4 h-4" />
</button>
{showMoreMenu && (
<div className="absolute bottom-full right-0 mb-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 min-w-[150px] z-10">
{onBatchDuplicate && (
<button
onClick={() => {
onBatchDuplicate();
setShowMoreMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<Copy className="w-4 h-4" />
</button>
)}
{onBatchDelete && (
<button
onClick={() => {
onBatchDelete();
setShowMoreMenu(false);
}}
className="w-full flex items-center gap-2 px-3 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
)}
</div>
)}
{/* Close */}
<button
onClick={onDeselectAll}
className="p-1.5 text-orange-600 dark:text-orange-400 hover:bg-orange-100 dark:hover:bg-orange-900/30 rounded-md"
title="取消选择"
>
<XCircle className="w-4 h-4" />
</button>
</div>
</div>
</div>
);
}
export default BatchActionBar;

View File

@@ -0,0 +1,378 @@
/**
* ScheduleEditor - Visual Schedule Configuration
*
* Provides a visual interface for configuring schedules
* without requiring knowledge of cron syntax.
*
* @module components/Automation/ScheduleEditor
*/
import { useState, useCallback, useMemo } from 'react';
import type { ScheduleInfo } from '../../types/automation';
import {
Calendar,
Info,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
// === Frequency Types ===
type Frequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
// === Timezones ===
const COMMON_TIMEZONES = [
{ value: 'Asia/Shanghai', label: '北京时间 (UTC+8)' },
{ value: 'Asia/Tokyo', label: '东京时间 (UTC+9)' },
{ value: 'Asia/Singapore', label: '新加坡时间 (UTC+8)' },
{ value: 'America/New_York', label: '纽约时间 (UTC-5)' },
{ value: 'America/Los_Angeles', label: '洛杉矶时间 (UTC-8)' },
{ value: 'Europe/London', label: '伦敦时间 (UTC+0)' },
{ value: 'UTC', label: '协调世界时 (UTC)' },
];
// === Day Names ===
const DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
const DAY_NAMES_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
// === Component Props ===
interface ScheduleEditorProps {
schedule?: ScheduleInfo;
onSave: (schedule: ScheduleInfo) => void;
onCancel: () => void;
itemName?: string;
}
// === Helper Functions ===
function formatSchedulePreview(schedule: ScheduleInfo): string {
const { frequency, time, daysOfWeek, dayOfMonth, timezone } = schedule;
const timeStr = `${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}`;
const tzLabel = COMMON_TIMEZONES.find(tz => tz.value === timezone)?.label || timezone;
switch (frequency) {
case 'once':
return `一次性执行于 ${timeStr} (${tzLabel})`;
case 'daily':
return `每天 ${timeStr} (${tzLabel})`;
case 'weekly':
const days = (daysOfWeek || []).map(d => DAY_NAMES_FULL[d]).join('、');
return `${days} ${timeStr} (${tzLabel})`;
case 'monthly':
return `每月${dayOfMonth || 1}${timeStr} (${tzLabel})`;
case 'custom':
return schedule.customCron || '自定义调度';
default:
return '未设置';
}
}
// === Main Component ===
export function ScheduleEditor({
schedule,
onSave,
onCancel,
itemName = '自动化项目',
}: ScheduleEditorProps) {
const { toast } = useToast();
// Initialize state from existing schedule
const [frequency, setFrequency] = useState<Frequency>(schedule?.frequency || 'daily');
const [time, setTime] = useState(schedule?.time || { hour: 9, minute: 0 });
const [daysOfWeek, setDaysOfWeek] = useState<number[]>(schedule?.daysOfWeek || [1, 2, 3, 4, 5]);
const [dayOfMonth, setDayOfMonth] = useState(schedule?.dayOfMonth || 1);
const [timezone, setTimezone] = useState(schedule?.timezone || 'Asia/Shanghai');
const [endDate, setEndDate] = useState(schedule?.endDate || '');
const [customCron, setCustomCron] = useState(schedule?.customCron || '');
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
// Toggle day of week
const toggleDayOfWeek = useCallback((day: number) => {
setDaysOfWeek(prev =>
prev.includes(day)
? prev.filter(d => d !== day)
: [...prev, day].sort()
);
}, []);
// Handle save
const handleSave = useCallback(() => {
// Validate
if (frequency === 'weekly' && daysOfWeek.length === 0) {
toast('请选择至少一个重复日期', 'error');
return;
}
if (frequency === 'custom' && !customCron) {
toast('请输入自定义 cron 表达式', 'error');
return;
}
const newSchedule: ScheduleInfo = {
enabled,
frequency,
time,
daysOfWeek: frequency === 'weekly' ? daysOfWeek : undefined,
dayOfMonth: frequency === 'monthly' ? dayOfMonth : undefined,
customCron: frequency === 'custom' ? customCron : undefined,
timezone,
endDate: endDate || undefined,
};
onSave(newSchedule);
toast('调度设置已保存', 'success');
}, [frequency, daysOfWeek, customCron, enabled, time, dayOfMonth, timezone, endDate, onSave, toast]);
// Generate preview
const preview = useMemo(() => {
return formatSchedulePreview({
enabled,
frequency,
time,
daysOfWeek,
dayOfMonth,
customCron,
timezone,
});
}, [enabled, frequency, time, daysOfWeek, dayOfMonth, customCron, timezone]);
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={onCancel}
/>
{/* 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">
<div className="p-2 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<Calendar className="w-5 h-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
{itemName && (
<p className="text-sm text-gray-500 dark:text-gray-400">{itemName}</p>
)}
</div>
</div>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<span className="text-xl">&times;</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-orange-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Frequency Selection */}
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="grid grid-cols-5 gap-2">
{[
{ value: 'once', label: '一次' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'custom', label: '自定义' },
].map(option => (
<button
key={option.value}
onClick={() => setFrequency(option.value as Frequency)}
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
frequency === option.value
? 'bg-orange-500 text-white border-orange-500'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Time Selection */}
{frequency !== 'custom' && (
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="23"
value={time.hour}
onChange={(e) => setTime(prev => ({ ...prev, hour: parseInt(e.target.value) || 0 }))}
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
<span className="text-gray-500 dark:text-gray-400">:</span>
<input
type="number"
min="0"
max="59"
value={time.minute}
onChange={(e) => setTime(prev => ({ ...prev, minute: parseInt(e.target.value) || 0 }))}
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{COMMON_TIMEZONES.map(tz => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</div>
</div>
)}
{/* Weekly Days Selection */}
{frequency === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="flex items-center gap-1">
{DAY_NAMES.map((day, index) => (
<button
key={index}
onClick={() => toggleDayOfWeek(index)}
className={`w-10 h-10 rounded-full text-sm font-medium transition-colors ${
daysOfWeek.includes(index)
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{day}
</button>
))}
</div>
</div>
)}
{/* Monthly Day Selection */}
{frequency === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<select
value={dayOfMonth}
onChange={(e) => setDayOfMonth(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}> {day} </option>
))}
</select>
</div>
)}
{/* Custom Cron Input */}
{frequency === 'custom' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Cron
</label>
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder="* * * * * (分 时 日 月 周)"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Info className="w-3 h-3" />
: "0 9 * * *" 9:00
</p>
</div>
)}
{/* End Date */}
{frequency !== 'once' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
()
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
)}
{/* Preview */}
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{preview}</p>
</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={onCancel}
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"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600"
>
</button>
</div>
</div>
</div>
);
}
export default ScheduleEditor;

View File

@@ -0,0 +1,24 @@
/**
* Automation Components
*
* Unified automation system components for Hands and Workflows.
*
* @module components/Automation
*/
export { AutomationPanel, default as AutomationPanelDefault } from './AutomationPanel';
export { AutomationCard } from './AutomationCard';
export { AutomationFilters } from './AutomationFilters';
export { BatchActionBar } from './BatchActionBar';
export { ScheduleEditor } from './ScheduleEditor';
// Re-export types
export type {
AutomationItem,
AutomationStatus,
AutomationType,
CategoryType,
CategoryStats,
RunInfo,
ScheduleInfo,
} from '../../types/automation';

View File

@@ -8,12 +8,17 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play } from 'lucide-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 ===
@@ -133,7 +138,7 @@ interface HandDetailsModalProps {
hand: Hand;
isOpen: boolean;
onClose: () => void;
onActivate: () => void;
onActivate: (params?: Record<string, unknown>) => void;
isActivating: boolean;
}
@@ -183,7 +188,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
return;
}
// Pass parameters to onActivate
onActivate();
onActivate(paramValues);
} else {
onActivate();
}
@@ -366,7 +371,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
interface HandCardProps {
hand: Hand;
onDetails: (hand: Hand) => void;
onActivate: (hand: Hand) => void;
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
isActivating: boolean;
}
@@ -450,10 +455,12 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps)
// === Main HandsPanel Component ===
export function HandsPanel() {
const { hands, loadHands, triggerHand, isLoading } = useGatewayStore();
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();
@@ -461,34 +468,47 @@ export function HandsPanel() {
const handleDetails = useCallback(async (hand: Hand) => {
// Load full details before showing modal
const { getHandDetails } = useGatewayStore.getState();
const details = await getHandDetails(hand.id);
setSelectedHand(details || hand);
setShowModal(true);
}, []);
}, [getHandDetails]);
const handleActivate = useCallback(async (hand: Hand) => {
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
setActivatingHandId(hand.id);
console.log(`[HandsPanel] Activating hand: ${hand.id} (${hand.name})`, params ? 'with params:' : '', params);
try {
await triggerHand(hand.id);
// Refresh hands after activation
await loadHands();
} catch {
// Error is handled in store
const result = await triggerHand(hand.id, params);
console.log(`[HandsPanel] Hand activation result:`, result);
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]);
}, [triggerHand, loadHands, toast, storeError]);
const handleCloseModal = useCallback(() => {
setShowModal(false);
setSelectedHand(null);
}, []);
const handleModalActivate = useCallback(async () => {
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
if (!selectedHand) return;
setShowModal(false);
await handleActivate(selectedHand);
await handleActivate(selectedHand, params);
}, [selectedHand, handleActivate]);
if (isLoading && hands.length === 0) {
@@ -540,21 +560,52 @@ export function HandsPanel() {
</button>
</div>
{/* 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>
{/* 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>
{/* 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');
{/* 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
@@ -571,17 +622,19 @@ export function HandsPanel() {
/>
);
})}
</div>
</div>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
{/* Details Modal */}
{selectedHand && (
<HandDetailsModal
hand={selectedHand}
isOpen={showModal}
onClose={handleCloseModal}
onActivate={handleModalActivate}
isActivating={activatingHandId === selectedHand.id}
/>
)}
</>
)}
</div>
);