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:
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;
|
||||
Reference in New Issue
Block a user