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

View File

@@ -0,0 +1,17 @@
/**
* Custom React Hooks for ZCLAW Desktop
*
* @module hooks
*/
export {
useAutomationEvents,
useHandEvents,
useWorkflowEvents,
} from './useAutomationEvents';
// Re-export types from useAutomationEvents
export type {
UseAutomationEventsOptions,
} from './useAutomationEvents';

View File

@@ -0,0 +1,318 @@
/**
* useAutomationEvents - WebSocket Event Hook for Automation System
*
* Subscribes to hand and workflow events from OpenFang WebSocket
* and updates the corresponding stores.
*
* @module hooks/useAutomationEvents
*/
import { useEffect, useRef } from 'react';
import { useHandStore } from '../store/handStore';
import { useWorkflowStore } from '../store/workflowStore';
import { useChatStore } from '../store/chatStore';
import type { GatewayClient } from '../lib/gateway-client';
// === Event Types ===
interface HandEventData {
hand_name: string;
hand_status: 'triggered' | 'running' | 'completed' | 'failed' | 'needs_approval';
hand_result?: unknown;
hand_error?: string;
run_id?: string;
timestamp?: number;
}
interface WorkflowEventData {
workflow_id: string;
workflow_status: 'started' | 'step_completed' | 'completed' | 'failed' | 'paused';
current_step?: number;
total_steps?: number;
step_name?: string;
result?: unknown;
error?: string;
run_id?: string;
timestamp?: number;
}
interface ApprovalEventData {
approval_id: string;
hand_name?: string;
workflow_id?: string;
run_id?: string;
status: 'requested' | 'approved' | 'rejected' | 'expired';
reason?: string;
requested_by?: string;
timestamp?: number;
}
// === Hook Options ===
export interface UseAutomationEventsOptions {
/** Whether to inject hand results into chat as messages */
injectResultsToChat?: boolean;
/** Whether to auto-refresh hands on status change */
refreshOnStatusChange?: boolean;
/** Custom event handlers */
onHandEvent?: (data: HandEventData) => void;
onWorkflowEvent?: (data: WorkflowEventData) => void;
onApprovalEvent?: (data: ApprovalEventData) => void;
}
// === Helper Functions ===
function isHandEvent(data: unknown): data is HandEventData {
return typeof data === 'object' && data !== null && 'hand_name' in data && 'hand_status' in data;
}
function isWorkflowEvent(data: unknown): data is WorkflowEventData {
return typeof data === 'object' && data !== null && 'workflow_id' in data && 'workflow_status' in data;
}
function isApprovalEvent(data: unknown): data is ApprovalEventData {
return typeof data === 'object' && data !== null && 'approval_id' in data && 'status' in data;
}
// === Main Hook ===
/**
* Hook for subscribing to automation-related WebSocket events.
*
* @param client - The GatewayClient instance (optional, will try to get from store if not provided)
* @param options - Configuration options
*
* @example
* ```tsx
* function AutomationPanel() {
* const client = useConnectionStore(s => s.client);
* useAutomationEvents(client, {
* injectResultsToChat: true,
* refreshOnStatusChange: true,
* });
* // ...
* }
* ```
*/
export function useAutomationEvents(
client: GatewayClient | null,
options: UseAutomationEventsOptions = {}
): void {
const {
injectResultsToChat = true,
refreshOnStatusChange = true,
onHandEvent,
onWorkflowEvent,
onApprovalEvent,
} = options;
// Store references
const loadHands = useHandStore(s => s.loadHands);
const loadHandRuns = useHandStore(s => s.loadHandRuns);
const loadApprovals = useHandStore(s => s.loadApprovals);
const loadWorkflows = useWorkflowStore(s => s.loadWorkflows);
const loadWorkflowRuns = useWorkflowStore(s => s.loadWorkflowRuns);
const addMessage = useChatStore(s => s.addMessage);
// Track subscriptions for cleanup
const unsubscribersRef = useRef<Array<() => void>>([]);
useEffect(() => {
if (!client) {
return;
}
// Clean up any existing subscriptions
unsubscribersRef.current.forEach(unsub => unsub());
unsubscribersRef.current = [];
// === Hand Event Handler ===
const handleHandEvent = (data: unknown) => {
if (!isHandEvent(data)) return;
const eventData = data as HandEventData;
console.log('[useAutomationEvents] Hand event:', eventData);
// Refresh hands if status changed
if (refreshOnStatusChange) {
loadHands();
}
// Load updated runs for this hand
if (eventData.run_id) {
loadHandRuns(eventData.hand_name);
}
// Inject result into chat
if (injectResultsToChat && eventData.hand_status === 'completed') {
const resultContent = eventData.hand_result
? typeof eventData.hand_result === 'string'
? eventData.hand_result
: JSON.stringify(eventData.hand_result, null, 2)
: 'Hand completed successfully';
addMessage({
id: `hand-${eventData.run_id || Date.now()}`,
role: 'hand',
content: `**${eventData.hand_name}** 执行完成\n\n${resultContent}`,
timestamp: new Date(),
handName: eventData.hand_name,
handStatus: eventData.hand_status,
handResult: eventData.hand_result,
runId: eventData.run_id,
});
}
// Handle error status
if (eventData.hand_status === 'failed' && eventData.hand_error) {
addMessage({
id: `hand-error-${eventData.run_id || Date.now()}`,
role: 'hand',
content: `**${eventData.hand_name}** 执行失败\n\n错误: ${eventData.hand_error}`,
timestamp: new Date(),
handName: eventData.hand_name,
handStatus: eventData.hand_status,
error: eventData.hand_error,
runId: eventData.run_id,
});
}
// Handle approval needed
if (eventData.hand_status === 'needs_approval') {
loadApprovals('pending');
}
// Call custom handler
onHandEvent?.(eventData);
};
// === Workflow Event Handler ===
const handleWorkflowEvent = (data: unknown) => {
if (!isWorkflowEvent(data)) return;
const eventData = data as WorkflowEventData;
console.log('[useAutomationEvents] Workflow event:', eventData);
// Refresh workflows if status changed
if (refreshOnStatusChange) {
loadWorkflows();
}
// Load updated runs for this workflow
if (eventData.run_id) {
loadWorkflowRuns(eventData.workflow_id);
}
// Inject result into chat
if (injectResultsToChat && eventData.workflow_status === 'completed') {
const resultContent = eventData.result
? typeof eventData.result === 'string'
? eventData.result
: JSON.stringify(eventData.result, null, 2)
: 'Workflow completed successfully';
addMessage({
id: `workflow-${eventData.run_id || Date.now()}`,
role: 'workflow',
content: `**工作流: ${eventData.workflow_id}** 执行完成\n\n${resultContent}`,
timestamp: new Date(),
workflowId: eventData.workflow_id,
workflowStatus: eventData.workflow_status,
workflowResult: eventData.result,
runId: eventData.run_id,
});
}
// Call custom handler
onWorkflowEvent?.(eventData);
};
// === Approval Event Handler ===
const handleApprovalEvent = (data: unknown) => {
if (!isApprovalEvent(data)) return;
const eventData = data as ApprovalEventData;
console.log('[useAutomationEvents] Approval event:', eventData);
// Refresh approvals list
loadApprovals();
// Call custom handler
onApprovalEvent?.(eventData);
};
// Subscribe to events
const unsubHand = client.on('hand', handleHandEvent);
const unsubWorkflow = client.on('workflow', handleWorkflowEvent);
const unsubApproval = client.on('approval', handleApprovalEvent);
unsubscribersRef.current = [unsubHand, unsubWorkflow, unsubApproval];
// Cleanup on unmount or client change
return () => {
unsubscribersRef.current.forEach(unsub => unsub());
unsubscribersRef.current = [];
};
}, [
client,
injectResultsToChat,
refreshOnStatusChange,
loadHands,
loadHandRuns,
loadApprovals,
loadWorkflows,
loadWorkflowRuns,
addMessage,
onHandEvent,
onWorkflowEvent,
onApprovalEvent,
]);
}
// === Utility Hooks ===
/**
* Hook for subscribing to a specific hand's events only
*/
export function useHandEvents(
client: GatewayClient | null,
handName: string,
onEvent?: (data: HandEventData) => void
): void {
useEffect(() => {
if (!client || !handName) return;
const handler = (data: unknown) => {
if (isHandEvent(data) && (data as HandEventData).hand_name === handName) {
onEvent?.(data as HandEventData);
}
};
const unsub = client.on('hand', handler);
return unsub;
}, [client, handName, onEvent]);
}
/**
* Hook for subscribing to a specific workflow's events only
*/
export function useWorkflowEvents(
client: GatewayClient | null,
workflowId: string,
onEvent?: (data: WorkflowEventData) => void
): void {
useEffect(() => {
if (!client || !workflowId) return;
const handler = (data: unknown) => {
if (isWorkflowEvent(data) && (data as WorkflowEventData).workflow_id === workflowId) {
onEvent?.(data as WorkflowEventData);
}
};
const unsub = client.on('workflow', handler);
return unsub;
}, [client, workflowId, onEvent]);
}
export default useAutomationEvents;

View File

@@ -0,0 +1,363 @@
/**
* Automation Type Adapters for ZCLAW
*
* This module provides unified types for the Automation system,
* combining Hands and Workflows into a single AutomationItem type.
*
* @module types/automation
*/
import type { Hand, HandStatus, HandParameter } from './hands';
import type { Workflow, WorkflowRunStatus } from './workflow';
// === Category Types ===
/**
* Category types for classifying automation items
*/
export type CategoryType = 'all' | 'research' | 'data' | 'automation' | 'communication' | 'content' | 'productivity';
/**
* Category configuration for display
*/
export interface CategoryConfig {
id: CategoryType;
label: string;
icon: string;
description: string;
}
/**
* Category statistics for filtering UI
*/
export interface CategoryStats {
all: number;
research: number;
data: number;
automation: number;
communication: number;
content: number;
productivity: number;
}
// === Category Mapping for Hands ===
/**
* Maps Hand IDs to their categories
*/
export const HAND_CATEGORY_MAP: Record<string, CategoryType> = {
researcher: 'research',
browser: 'research',
collector: 'data',
predictor: 'data',
lead: 'communication',
twitter: 'communication',
clip: 'content',
};
/**
* Category configurations for UI display
*/
export const CATEGORY_CONFIGS: Record<CategoryType, CategoryConfig> = {
all: {
id: 'all',
label: '全部',
icon: 'Layers',
description: '所有自动化项目',
},
research: {
id: 'research',
label: '研究',
icon: 'Search',
description: '深度研究和浏览器自动化',
},
data: {
id: 'data',
label: '数据',
icon: 'Database',
description: '数据收集和预测分析',
},
automation: {
id: 'automation',
label: '自动化',
icon: 'Zap',
description: '工作流和触发器',
},
communication: {
id: 'communication',
label: '通信',
icon: 'MessageSquare',
description: '销售线索和社交媒体',
},
content: {
id: 'content',
label: '内容',
icon: 'Video',
description: '视频和内容处理',
},
productivity: {
id: 'productivity',
label: '生产力',
icon: 'TrendingUp',
description: '效率提升工具',
},
};
// === Automation Item (Unified Type) ===
/**
* Execution status for automation items
*/
export type AutomationStatus = 'idle' | 'running' | 'needs_approval' | 'error' | 'unavailable' | 'setup_needed' | 'completed' | 'paused';
/**
* Item type discriminator
*/
export type AutomationType = 'hand' | 'workflow';
/**
* Run information for last execution
*/
export interface RunInfo {
runId: string;
status: AutomationStatus;
startedAt: string;
completedAt?: string;
duration?: number;
output?: unknown;
error?: string;
}
/**
* Schedule information for automation items
*/
export interface ScheduleInfo {
enabled: boolean;
frequency: 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
time: { hour: number; minute: number };
daysOfWeek?: number[]; // 0-6 for weekly
dayOfMonth?: number; // 1-31 for monthly
customCron?: string; // Advanced mode
timezone: string;
endDate?: string;
nextRun?: string;
}
/**
* Unified automation item type
* Adapts both Hand and Workflow into a common interface
*/
export interface AutomationItem {
// Identity
id: string;
name: string;
description: string;
type: AutomationType;
category: CategoryType;
// Status
status: AutomationStatus;
error?: string;
// Configuration
parameters?: HandParameter[];
requiresApproval: boolean;
// Execution info
lastRun?: RunInfo;
schedule?: ScheduleInfo;
currentRunId?: string;
// Display
icon?: string;
// Type-specific data
handData?: Hand;
workflowData?: Workflow;
}
// === Type Adapters ===
/**
* Converts Hand status to Automation status
*/
export function handStatusToAutomationStatus(status: HandStatus): AutomationStatus {
const statusMap: Record<HandStatus, AutomationStatus> = {
idle: 'idle',
running: 'running',
needs_approval: 'needs_approval',
error: 'error',
unavailable: 'unavailable',
setup_needed: 'setup_needed',
};
return statusMap[status] || 'unavailable';
}
/**
* Converts Workflow run status to Automation status
*/
export function workflowStatusToAutomationStatus(status: WorkflowRunStatus): AutomationStatus {
const statusMap: Record<WorkflowRunStatus, AutomationStatus> = {
pending: 'idle',
running: 'running',
completed: 'completed',
failed: 'error',
cancelled: 'idle',
paused: 'paused',
};
return statusMap[status] || 'idle';
}
/**
* Adapts a Hand to an AutomationItem
*/
export function handToAutomationItem(hand: Hand): AutomationItem {
const category = HAND_CATEGORY_MAP[hand.id] || HAND_CATEGORY_MAP[hand.name.toLowerCase()] || 'productivity';
return {
id: hand.id,
name: hand.name,
description: hand.description,
type: 'hand',
category,
status: handStatusToAutomationStatus(hand.status),
error: hand.error,
parameters: hand.parameters,
requiresApproval: false, // Will be determined by execution result
lastRun: hand.lastRun ? {
runId: hand.lastRun,
status: 'completed',
startedAt: hand.lastRun,
} : undefined,
icon: hand.icon,
handData: hand,
};
}
/**
* Adapts a Workflow to an AutomationItem
* Handles both store Workflow (steps: number) and full Workflow (steps: WorkflowStep[])
*/
export function workflowToAutomationItem(workflow: Workflow | { id: string; name: string; steps: number; description?: string; createdAt?: string }): AutomationItem {
// For store workflows with steps as number, default to automation category
const stepsArray = Array.isArray(workflow.steps) ? workflow.steps : [];
// Determine category based on workflow steps (only if steps is an array)
let category: CategoryType = 'automation';
if (stepsArray.length > 0 && 'handName' in stepsArray[0]) {
const typedSteps = stepsArray as Array<{ handName?: string }>;
if (typedSteps.some(s => s.handName === 'researcher' || s.handName === 'browser')) {
category = 'research';
} else if (typedSteps.some(s => s.handName === 'collector' || s.handName === 'predictor')) {
category = 'data';
} else if (typedSteps.some(s => s.handName === 'lead' || s.handName === 'twitter')) {
category = 'communication';
} else if (typedSteps.some(s => s.handName === 'clip')) {
category = 'content';
}
}
return {
id: workflow.id,
name: workflow.name,
description: workflow.description || '',
type: 'workflow',
category,
status: 'idle',
requiresApproval: false,
workflowData: 'steps' in workflow && Array.isArray(workflow.steps) ? workflow as Workflow : undefined,
};
}
/**
* Store Workflow type (from gatewayStore/workflowStore)
* Has steps as number (count) instead of array
*/
export interface StoreWorkflow {
id: string;
name: string;
steps: number;
description?: string;
createdAt?: string;
}
/**
* Adapts an array of Hands and Workflows to AutomationItems
* Accepts both full Workflow type and store Workflow type
*/
export function adaptToAutomationItems(
hands: Hand[] = [],
workflows: (Workflow | StoreWorkflow)[] = []
): AutomationItem[] {
const handItems = hands.map(handToAutomationItem);
const workflowItems = workflows.map(workflowToAutomationItem);
return [...handItems, ...workflowItems];
}
/**
* Calculates category statistics from automation items
*/
export function calculateCategoryStats(items: AutomationItem[]): CategoryStats {
const stats: CategoryStats = {
all: items.length,
research: 0,
data: 0,
automation: 0,
communication: 0,
content: 0,
productivity: 0,
};
for (const item of items) {
if (item.category !== 'all') {
stats[item.category]++;
}
}
return stats;
}
/**
* Filters automation items by category
*/
export function filterByCategory(items: AutomationItem[], category: CategoryType): AutomationItem[] {
if (category === 'all') {
return items;
}
return items.filter(item => item.category === category);
}
/**
* Filters automation items by type
*/
export function filterByType(items: AutomationItem[], type: AutomationType | 'all'): AutomationItem[] {
if (type === 'all') {
return items;
}
return items.filter(item => item.type === type);
}
/**
* Filters automation items by status
*/
export function filterByStatus(items: AutomationItem[], statuses: AutomationStatus[]): AutomationItem[] {
if (statuses.length === 0) {
return items;
}
return items.filter(item => statuses.includes(item.status));
}
/**
* Searches automation items by name or description
*/
export function searchAutomationItems(items: AutomationItem[], query: string): AutomationItem[] {
if (!query.trim()) {
return items;
}
const lowerQuery = query.toLowerCase();
return items.filter(
item =>
item.name.toLowerCase().includes(lowerQuery) ||
item.description.toLowerCase().includes(lowerQuery)
);
}

View File

@@ -149,3 +149,31 @@ export {
createErrorResponse,
createPaginatedResponse,
} from './api-responses';
// Automation Types
export type {
CategoryType,
CategoryConfig,
CategoryStats,
AutomationStatus,
AutomationType,
RunInfo,
ScheduleInfo,
AutomationItem,
} from './automation';
// Automation Constants and Functions
export {
HAND_CATEGORY_MAP,
CATEGORY_CONFIGS,
handStatusToAutomationStatus,
workflowStatusToAutomationStatus,
handToAutomationItem,
workflowToAutomationItem,
adaptToAutomationItems,
calculateCategoryStats,
filterByCategory,
filterByType,
filterByStatus,
searchAutomationItems,
} from './automation';