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>
379 lines
14 KiB
TypeScript
379 lines
14 KiB
TypeScript
/**
|
||
* 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;
|