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