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,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;