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;
|
||||||
204
desktop/src/components/Automation/AutomationFilters.tsx
Normal file
204
desktop/src/components/Automation/AutomationFilters.tsx
Normal 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;
|
||||||
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;
|
||||||
216
desktop/src/components/Automation/BatchActionBar.tsx
Normal file
216
desktop/src/components/Automation/BatchActionBar.tsx
Normal 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;
|
||||||
378
desktop/src/components/Automation/ScheduleEditor.tsx
Normal file
378
desktop/src/components/Automation/ScheduleEditor.tsx
Normal 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">×</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;
|
||||||
24
desktop/src/components/Automation/index.ts
Normal file
24
desktop/src/components/Automation/index.ts
Normal 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';
|
||||||
@@ -8,12 +8,17 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
|
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
|
||||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play } from 'lucide-react';
|
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react';
|
||||||
import { BrowserHandCard } from './BrowserHand';
|
import { BrowserHandCard } from './BrowserHand';
|
||||||
import type { HandParameter } from '../types/hands';
|
import type { HandParameter } from '../types/hands';
|
||||||
import { HAND_DEFINITIONS } from '../types/hands';
|
import { HAND_DEFINITIONS } from '../types/hands';
|
||||||
import { HandParamsForm } from './HandParamsForm';
|
import { HandParamsForm } from './HandParamsForm';
|
||||||
|
import { ApprovalsPanel } from './ApprovalsPanel';
|
||||||
|
import { useToast } from './ui/Toast';
|
||||||
|
|
||||||
|
// === Tab Type ===
|
||||||
|
type TabType = 'hands' | 'approvals';
|
||||||
|
|
||||||
// === Status Badge Component ===
|
// === Status Badge Component ===
|
||||||
|
|
||||||
@@ -133,7 +138,7 @@ interface HandDetailsModalProps {
|
|||||||
hand: Hand;
|
hand: Hand;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onActivate: () => void;
|
onActivate: (params?: Record<string, unknown>) => void;
|
||||||
isActivating: boolean;
|
isActivating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +188,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Pass parameters to onActivate
|
// Pass parameters to onActivate
|
||||||
onActivate();
|
onActivate(paramValues);
|
||||||
} else {
|
} else {
|
||||||
onActivate();
|
onActivate();
|
||||||
}
|
}
|
||||||
@@ -366,7 +371,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
|||||||
interface HandCardProps {
|
interface HandCardProps {
|
||||||
hand: Hand;
|
hand: Hand;
|
||||||
onDetails: (hand: Hand) => void;
|
onDetails: (hand: Hand) => void;
|
||||||
onActivate: (hand: Hand) => void;
|
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
|
||||||
isActivating: boolean;
|
isActivating: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -450,10 +455,12 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps)
|
|||||||
// === Main HandsPanel Component ===
|
// === Main HandsPanel Component ===
|
||||||
|
|
||||||
export function HandsPanel() {
|
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 [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
||||||
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState<TabType>('hands');
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadHands();
|
loadHands();
|
||||||
@@ -461,34 +468,47 @@ export function HandsPanel() {
|
|||||||
|
|
||||||
const handleDetails = useCallback(async (hand: Hand) => {
|
const handleDetails = useCallback(async (hand: Hand) => {
|
||||||
// Load full details before showing modal
|
// Load full details before showing modal
|
||||||
const { getHandDetails } = useGatewayStore.getState();
|
|
||||||
const details = await getHandDetails(hand.id);
|
const details = await getHandDetails(hand.id);
|
||||||
setSelectedHand(details || hand);
|
setSelectedHand(details || hand);
|
||||||
setShowModal(true);
|
setShowModal(true);
|
||||||
}, []);
|
}, [getHandDetails]);
|
||||||
|
|
||||||
const handleActivate = useCallback(async (hand: Hand) => {
|
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
|
||||||
setActivatingHandId(hand.id);
|
setActivatingHandId(hand.id);
|
||||||
|
console.log(`[HandsPanel] Activating hand: ${hand.id} (${hand.name})`, params ? 'with params:' : '', params);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await triggerHand(hand.id);
|
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
|
// Refresh hands after activation
|
||||||
await loadHands();
|
await loadHands();
|
||||||
} catch {
|
} else {
|
||||||
// Error is handled in store
|
// 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 {
|
} finally {
|
||||||
setActivatingHandId(null);
|
setActivatingHandId(null);
|
||||||
}
|
}
|
||||||
}, [triggerHand, loadHands]);
|
}, [triggerHand, loadHands, toast, storeError]);
|
||||||
|
|
||||||
const handleCloseModal = useCallback(() => {
|
const handleCloseModal = useCallback(() => {
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
setSelectedHand(null);
|
setSelectedHand(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleModalActivate = useCallback(async () => {
|
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
|
||||||
if (!selectedHand) return;
|
if (!selectedHand) return;
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
await handleActivate(selectedHand);
|
await handleActivate(selectedHand, params);
|
||||||
}, [selectedHand, handleActivate]);
|
}, [selectedHand, handleActivate]);
|
||||||
|
|
||||||
if (isLoading && hands.length === 0) {
|
if (isLoading && hands.length === 0) {
|
||||||
@@ -540,6 +560,37 @@ export function HandsPanel() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
{activeTab === 'approvals' ? (
|
||||||
|
<ApprovalsPanel />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{/* Stats */}
|
{/* Stats */}
|
||||||
<div className="flex items-center gap-4 text-sm">
|
<div className="flex items-center gap-4 text-sm">
|
||||||
<span className="text-gray-500 dark:text-gray-400">
|
<span className="text-gray-500 dark:text-gray-400">
|
||||||
@@ -583,6 +634,8 @@ export function HandsPanel() {
|
|||||||
isActivating={activatingHandId === selectedHand.id}
|
isActivating={activatingHandId === selectedHand.id}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
17
desktop/src/hooks/index.ts
Normal file
17
desktop/src/hooks/index.ts
Normal 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';
|
||||||
|
|
||||||
318
desktop/src/hooks/useAutomationEvents.ts
Normal file
318
desktop/src/hooks/useAutomationEvents.ts
Normal 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;
|
||||||
363
desktop/src/types/automation.ts
Normal file
363
desktop/src/types/automation.ts
Normal 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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -149,3 +149,31 @@ export {
|
|||||||
createErrorResponse,
|
createErrorResponse,
|
||||||
createPaginatedResponse,
|
createPaginatedResponse,
|
||||||
} from './api-responses';
|
} 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';
|
||||||
|
|||||||
Reference in New Issue
Block a user