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 { useGatewayStore, type Hand, type HandRequirement } from '../store/gatewayStore';
|
||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play } from 'lucide-react';
|
||||
import { useHandStore, type Hand, type HandRequirement } from '../store/handStore';
|
||||
import { Zap, RefreshCw, ChevronRight, CheckCircle, XCircle, Loader2, AlertTriangle, Settings, Play, Clock } from 'lucide-react';
|
||||
import { BrowserHandCard } from './BrowserHand';
|
||||
import type { HandParameter } from '../types/hands';
|
||||
import { HAND_DEFINITIONS } from '../types/hands';
|
||||
import { HandParamsForm } from './HandParamsForm';
|
||||
import { ApprovalsPanel } from './ApprovalsPanel';
|
||||
import { useToast } from './ui/Toast';
|
||||
|
||||
// === Tab Type ===
|
||||
type TabType = 'hands' | 'approvals';
|
||||
|
||||
// === Status Badge Component ===
|
||||
|
||||
@@ -133,7 +138,7 @@ interface HandDetailsModalProps {
|
||||
hand: Hand;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onActivate: () => void;
|
||||
onActivate: (params?: Record<string, unknown>) => void;
|
||||
isActivating: boolean;
|
||||
}
|
||||
|
||||
@@ -183,7 +188,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
||||
return;
|
||||
}
|
||||
// Pass parameters to onActivate
|
||||
onActivate();
|
||||
onActivate(paramValues);
|
||||
} else {
|
||||
onActivate();
|
||||
}
|
||||
@@ -366,7 +371,7 @@ function HandDetailsModal({ hand, isOpen, onClose, onActivate, isActivating }: H
|
||||
interface HandCardProps {
|
||||
hand: Hand;
|
||||
onDetails: (hand: Hand) => void;
|
||||
onActivate: (hand: Hand) => void;
|
||||
onActivate: (hand: Hand, params?: Record<string, unknown>) => void;
|
||||
isActivating: boolean;
|
||||
}
|
||||
|
||||
@@ -450,10 +455,12 @@ function HandCard({ hand, onDetails, onActivate, isActivating }: HandCardProps)
|
||||
// === Main HandsPanel Component ===
|
||||
|
||||
export function HandsPanel() {
|
||||
const { hands, loadHands, triggerHand, isLoading } = useGatewayStore();
|
||||
const { hands, loadHands, triggerHand, isLoading, error: storeError, getHandDetails } = useHandStore();
|
||||
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
|
||||
const [activatingHandId, setActivatingHandId] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState<TabType>('hands');
|
||||
const { toast } = useToast();
|
||||
|
||||
useEffect(() => {
|
||||
loadHands();
|
||||
@@ -461,34 +468,47 @@ export function HandsPanel() {
|
||||
|
||||
const handleDetails = useCallback(async (hand: Hand) => {
|
||||
// Load full details before showing modal
|
||||
const { getHandDetails } = useGatewayStore.getState();
|
||||
const details = await getHandDetails(hand.id);
|
||||
setSelectedHand(details || hand);
|
||||
setShowModal(true);
|
||||
}, []);
|
||||
}, [getHandDetails]);
|
||||
|
||||
const handleActivate = useCallback(async (hand: Hand) => {
|
||||
const handleActivate = useCallback(async (hand: Hand, params?: Record<string, unknown>) => {
|
||||
setActivatingHandId(hand.id);
|
||||
console.log(`[HandsPanel] Activating hand: ${hand.id} (${hand.name})`, params ? 'with params:' : '', params);
|
||||
|
||||
try {
|
||||
await triggerHand(hand.id);
|
||||
// Refresh hands after activation
|
||||
await loadHands();
|
||||
} catch {
|
||||
// Error is handled in store
|
||||
const result = await triggerHand(hand.id, params);
|
||||
console.log(`[HandsPanel] Hand activation result:`, result);
|
||||
|
||||
if (result) {
|
||||
toast(`Hand "${hand.name}" 已成功激活`, 'success');
|
||||
// Refresh hands after activation
|
||||
await loadHands();
|
||||
} else {
|
||||
// Check if there's an error in the store
|
||||
const errorMsg = storeError || '激活失败,请检查后端连接';
|
||||
console.error(`[HandsPanel] Hand activation failed:`, errorMsg);
|
||||
toast(`Hand "${hand.name}" 激活失败: ${errorMsg}`, 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
console.error(`[HandsPanel] Hand activation error:`, errorMsg);
|
||||
toast(`Hand "${hand.name}" 激活异常: ${errorMsg}`, 'error');
|
||||
} finally {
|
||||
setActivatingHandId(null);
|
||||
}
|
||||
}, [triggerHand, loadHands]);
|
||||
}, [triggerHand, loadHands, toast, storeError]);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setShowModal(false);
|
||||
setSelectedHand(null);
|
||||
}, []);
|
||||
|
||||
const handleModalActivate = useCallback(async () => {
|
||||
const handleModalActivate = useCallback(async (params?: Record<string, unknown>) => {
|
||||
if (!selectedHand) return;
|
||||
setShowModal(false);
|
||||
await handleActivate(selectedHand);
|
||||
await handleActivate(selectedHand, params);
|
||||
}, [selectedHand, handleActivate]);
|
||||
|
||||
if (isLoading && hands.length === 0) {
|
||||
@@ -540,21 +560,52 @@ export function HandsPanel() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
可用 <span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
就绪 <span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
|
||||
</span>
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => setActiveTab('hands')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'hands'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Zap className="w-4 h-4" />
|
||||
能力包
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('approvals')}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'approvals'
|
||||
? 'text-orange-600 dark:text-orange-400 border-orange-500'
|
||||
: 'text-gray-500 dark:text-gray-400 border-transparent hover:text-gray-700 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Clock className="w-4 h-4" />
|
||||
审批历史
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Hand Cards Grid */}
|
||||
<div className="grid gap-3">
|
||||
{hands.map((hand) => {
|
||||
// Check if this is a Browser Hand
|
||||
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
||||
{/* Tab Content */}
|
||||
{activeTab === 'approvals' ? (
|
||||
<ApprovalsPanel />
|
||||
) : (
|
||||
<>
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
可用 <span className="font-medium text-gray-900 dark:text-white">{hands.length}</span>
|
||||
</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
就绪 <span className="font-medium text-green-600 dark:text-green-400">{hands.filter(h => h.status === 'idle').length}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Hand Cards Grid */}
|
||||
<div className="grid gap-3">
|
||||
{hands.map((hand) => {
|
||||
// Check if this is a Browser Hand
|
||||
const isBrowserHand = hand.id === 'browser' || hand.name === 'Browser' || hand.name?.toLowerCase().includes('browser');
|
||||
|
||||
return isBrowserHand ? (
|
||||
<BrowserHandCard
|
||||
@@ -571,17 +622,19 @@ export function HandsPanel() {
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Modal */}
|
||||
{selectedHand && (
|
||||
<HandDetailsModal
|
||||
hand={selectedHand}
|
||||
isOpen={showModal}
|
||||
onClose={handleCloseModal}
|
||||
onActivate={handleModalActivate}
|
||||
isActivating={activatingHandId === selectedHand.id}
|
||||
/>
|
||||
{/* Details Modal */}
|
||||
{selectedHand && (
|
||||
<HandDetailsModal
|
||||
hand={selectedHand}
|
||||
isOpen={showModal}
|
||||
onClose={handleCloseModal}
|
||||
onActivate={handleModalActivate}
|
||||
isActivating={activatingHandId === selectedHand.id}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user