feat(automation): implement unified automation system with Hands and Workflows

Phase 1 - Core Fixes:
- Fix parameter passing in HandsPanel (params now passed to triggerHand)
- Migrate HandsPanel from useGatewayStore to useHandStore
- Add type adapters and category mapping for 7 Hands
- Create useAutomationEvents hook for WebSocket event handling

Phase 2 - UI Components:
- Create AutomationPanel as unified entry point
- Create AutomationCard with grid/list view support
- Create AutomationFilters with category tabs and search
- Create BatchActionBar for batch operations

Phase 3 - Advanced Features:
- Create ScheduleEditor with visual scheduling (no cron syntax)
- Support frequency: once, daily, weekly, monthly, custom
- Add timezone selection and end date options

Technical Details:
- AutomationItem type unifies Hand and Workflow
- CategoryType: research, data, automation, communication, content, productivity
- ScheduleInfo interface for scheduling configuration
- WebSocket events: hand, workflow, approval

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-18 16:32:18 +08:00
parent dfeb286591
commit 3a7631e035
11 changed files with 2321 additions and 40 deletions

View File

@@ -0,0 +1,378 @@
/**
* ScheduleEditor - Visual Schedule Configuration
*
* Provides a visual interface for configuring schedules
* without requiring knowledge of cron syntax.
*
* @module components/Automation/ScheduleEditor
*/
import { useState, useCallback, useMemo } from 'react';
import type { ScheduleInfo } from '../../types/automation';
import {
Calendar,
Info,
} from 'lucide-react';
import { useToast } from '../ui/Toast';
// === Frequency Types ===
type Frequency = 'once' | 'daily' | 'weekly' | 'monthly' | 'custom';
// === Timezones ===
const COMMON_TIMEZONES = [
{ value: 'Asia/Shanghai', label: '北京时间 (UTC+8)' },
{ value: 'Asia/Tokyo', label: '东京时间 (UTC+9)' },
{ value: 'Asia/Singapore', label: '新加坡时间 (UTC+8)' },
{ value: 'America/New_York', label: '纽约时间 (UTC-5)' },
{ value: 'America/Los_Angeles', label: '洛杉矶时间 (UTC-8)' },
{ value: 'Europe/London', label: '伦敦时间 (UTC+0)' },
{ value: 'UTC', label: '协调世界时 (UTC)' },
];
// === Day Names ===
const DAY_NAMES = ['日', '一', '二', '三', '四', '五', '六'];
const DAY_NAMES_FULL = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
// === Component Props ===
interface ScheduleEditorProps {
schedule?: ScheduleInfo;
onSave: (schedule: ScheduleInfo) => void;
onCancel: () => void;
itemName?: string;
}
// === Helper Functions ===
function formatSchedulePreview(schedule: ScheduleInfo): string {
const { frequency, time, daysOfWeek, dayOfMonth, timezone } = schedule;
const timeStr = `${time.hour.toString().padStart(2, '0')}:${time.minute.toString().padStart(2, '0')}`;
const tzLabel = COMMON_TIMEZONES.find(tz => tz.value === timezone)?.label || timezone;
switch (frequency) {
case 'once':
return `一次性执行于 ${timeStr} (${tzLabel})`;
case 'daily':
return `每天 ${timeStr} (${tzLabel})`;
case 'weekly':
const days = (daysOfWeek || []).map(d => DAY_NAMES_FULL[d]).join('、');
return `${days} ${timeStr} (${tzLabel})`;
case 'monthly':
return `每月${dayOfMonth || 1}${timeStr} (${tzLabel})`;
case 'custom':
return schedule.customCron || '自定义调度';
default:
return '未设置';
}
}
// === Main Component ===
export function ScheduleEditor({
schedule,
onSave,
onCancel,
itemName = '自动化项目',
}: ScheduleEditorProps) {
const { toast } = useToast();
// Initialize state from existing schedule
const [frequency, setFrequency] = useState<Frequency>(schedule?.frequency || 'daily');
const [time, setTime] = useState(schedule?.time || { hour: 9, minute: 0 });
const [daysOfWeek, setDaysOfWeek] = useState<number[]>(schedule?.daysOfWeek || [1, 2, 3, 4, 5]);
const [dayOfMonth, setDayOfMonth] = useState(schedule?.dayOfMonth || 1);
const [timezone, setTimezone] = useState(schedule?.timezone || 'Asia/Shanghai');
const [endDate, setEndDate] = useState(schedule?.endDate || '');
const [customCron, setCustomCron] = useState(schedule?.customCron || '');
const [enabled, setEnabled] = useState(schedule?.enabled ?? true);
// Toggle day of week
const toggleDayOfWeek = useCallback((day: number) => {
setDaysOfWeek(prev =>
prev.includes(day)
? prev.filter(d => d !== day)
: [...prev, day].sort()
);
}, []);
// Handle save
const handleSave = useCallback(() => {
// Validate
if (frequency === 'weekly' && daysOfWeek.length === 0) {
toast('请选择至少一个重复日期', 'error');
return;
}
if (frequency === 'custom' && !customCron) {
toast('请输入自定义 cron 表达式', 'error');
return;
}
const newSchedule: ScheduleInfo = {
enabled,
frequency,
time,
daysOfWeek: frequency === 'weekly' ? daysOfWeek : undefined,
dayOfMonth: frequency === 'monthly' ? dayOfMonth : undefined,
customCron: frequency === 'custom' ? customCron : undefined,
timezone,
endDate: endDate || undefined,
};
onSave(newSchedule);
toast('调度设置已保存', 'success');
}, [frequency, daysOfWeek, customCron, enabled, time, dayOfMonth, timezone, endDate, onSave, toast]);
// Generate preview
const preview = useMemo(() => {
return formatSchedulePreview({
enabled,
frequency,
time,
daysOfWeek,
dayOfMonth,
customCron,
timezone,
});
}, [enabled, frequency, time, daysOfWeek, dayOfMonth, customCron, timezone]);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onCancel}
/>
{/* Modal */}
<div className="relative bg-white dark:bg-gray-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 max-h-[90vh] overflow-hidden flex flex-col">
{/* Header */}
<div className="flex items-start justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-3">
<div className="p-2 bg-orange-100 dark:bg-orange-900/30 rounded-lg">
<Calendar className="w-5 h-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
</h2>
{itemName && (
<p className="text-sm text-gray-500 dark:text-gray-400">{itemName}</p>
)}
</div>
</div>
<button
onClick={onCancel}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 p-1"
>
<span className="text-xl">&times;</span>
</button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-4 space-y-6">
{/* Enable Toggle */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
</p>
<p className="text-xs text-gray-500 dark:text-gray-400">
</p>
</div>
<button
onClick={() => setEnabled(!enabled)}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
enabled ? 'bg-orange-500' : 'bg-gray-200 dark:bg-gray-700'
}`}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
enabled ? 'translate-x-6' : 'translate-x-1'
}`}
/>
</button>
</div>
{/* Frequency Selection */}
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="grid grid-cols-5 gap-2">
{[
{ value: 'once', label: '一次' },
{ value: 'daily', label: '每天' },
{ value: 'weekly', label: '每周' },
{ value: 'monthly', label: '每月' },
{ value: 'custom', label: '自定义' },
].map(option => (
<button
key={option.value}
onClick={() => setFrequency(option.value as Frequency)}
className={`px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${
frequency === option.value
? 'bg-orange-500 text-white border-orange-500'
: 'bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 border-gray-200 dark:border-gray-700 hover:border-orange-300 dark:hover:border-orange-700'
}`}
>
{option.label}
</button>
))}
</div>
</div>
{/* Time Selection */}
{frequency !== 'custom' && (
<div className="flex items-center gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="flex items-center gap-2">
<input
type="number"
min="0"
max="23"
value={time.hour}
onChange={(e) => setTime(prev => ({ ...prev, hour: parseInt(e.target.value) || 0 }))}
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
<span className="text-gray-500 dark:text-gray-400">:</span>
<input
type="number"
min="0"
max="59"
value={time.minute}
onChange={(e) => setTime(prev => ({ ...prev, minute: parseInt(e.target.value) || 0 }))}
className="w-16 px-3 py-2 text-center border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<select
value={timezone}
onChange={(e) => setTimezone(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{COMMON_TIMEZONES.map(tz => (
<option key={tz.value} value={tz.value}>{tz.label}</option>
))}
</select>
</div>
</div>
)}
{/* Weekly Days Selection */}
{frequency === 'weekly' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<div className="flex items-center gap-1">
{DAY_NAMES.map((day, index) => (
<button
key={index}
onClick={() => toggleDayOfWeek(index)}
className={`w-10 h-10 rounded-full text-sm font-medium transition-colors ${
daysOfWeek.includes(index)
? 'bg-orange-500 text-white'
: 'bg-gray-100 dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-700'
}`}
>
{day}
</button>
))}
</div>
</div>
)}
{/* Monthly Day Selection */}
{frequency === 'monthly' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
</label>
<select
value={dayOfMonth}
onChange={(e) => setDayOfMonth(parseInt(e.target.value))}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
>
{Array.from({ length: 31 }, (_, i) => i + 1).map(day => (
<option key={day} value={day}> {day} </option>
))}
</select>
</div>
)}
{/* Custom Cron Input */}
{frequency === 'custom' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
Cron
</label>
<input
type="text"
value={customCron}
onChange={(e) => setCustomCron(e.target.value)}
placeholder="* * * * * (分 时 日 月 周)"
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white font-mono text-sm"
/>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 flex items-center gap-1">
<Info className="w-3 h-3" />
: "0 9 * * *" 9:00
</p>
</div>
)}
{/* End Date */}
{frequency !== 'once' && (
<div>
<label className="block text-sm font-medium text-gray-900 dark:text-white mb-2">
()
</label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full px-3 py-2 border border-gray-200 dark:border-gray-700 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white"
/>
</div>
)}
{/* Preview */}
<div className="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
<p className="text-xs text-gray-500 dark:text-gray-400 mb-1"></p>
<p className="text-sm font-medium text-gray-900 dark:text-white">{preview}</p>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-2 p-4 border-t border-gray-200 dark:border-gray-700">
<button
onClick={onCancel}
className="px-4 py-2 text-sm border border-gray-200 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-300"
>
</button>
<button
onClick={handleSave}
className="px-4 py-2 text-sm bg-orange-500 text-white rounded-lg hover:bg-orange-600"
>
</button>
</div>
</div>
</div>
);
}
export default ScheduleEditor;