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:
278
desktop/src/components/Automation/AutomationPanel.tsx
Normal file
278
desktop/src/components/Automation/AutomationPanel.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user