Files
zclaw_openfang/desktop/src/components/Automation/ScheduleEditor.tsx
iven 3a7631e035 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>
2026-03-18 16:32:18 +08:00

379 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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;