diff --git a/web/src/components/CronInput/CronInput.tsx b/web/src/components/CronInput/CronInput.tsx index 7bb137c..e08d8fd 100644 --- a/web/src/components/CronInput/CronInput.tsx +++ b/web/src/components/CronInput/CronInput.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Space, Switch, Tabs, Typography, Radio, Select } from '@arco-design/web-react' +import { Button, Divider, Input, Select, Space, Switch, Typography } from '@arco-design/web-react' import { useEffect, useMemo, useState } from 'react' export interface CronInputProps { @@ -6,17 +6,7 @@ export interface CronInputProps { onChange?: (value: string) => void } -const DEFAULT_CRON = '* * * * *' - -type CronPart = 'minute' | 'hour' | 'day' | 'month' | 'week' - -interface CronState { - minute: string - hour: string - day: string - month: string - week: string -} +const DEFAULT_CRON = '0 2 * * *' // 常用预设 const PRESETS = [ @@ -27,249 +17,311 @@ const PRESETS = [ { label: '每周日 03:00', value: '0 3 * * 0' }, { label: '每月 1 日 02:00', value: '0 2 1 * *' }, { label: '每 30 分钟', value: '*/30 * * * *' }, - { label: '每小时', value: '0 * * * *' }, + { label: '每小时整点', value: '0 * * * *' }, ] -function parseCron(expr: string): CronState { - const parts = (expr || DEFAULT_CRON).trim().split(/\s+/) - return { - minute: parts[0] || '*', - hour: parts[1] || '*', - day: parts[2] || '*', - month: parts[3] || '*', - week: parts[4] || '*', - } -} +const HOUR_OPTIONS = Array.from({ length: 24 }, (_, i) => ({ + label: `${String(i).padStart(2, '0')} 时`, + value: String(i), +})) -function stringifyCron(state: CronState): string { - return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}` -} +const MINUTE_OPTIONS = Array.from({ length: 12 }, (_, i) => ({ + label: `${String(i * 5).padStart(2, '0')} 分`, + value: String(i * 5), +})) + +const WEEKDAY_OPTIONS = [ + { label: '周一', value: '1' }, + { label: '周二', value: '2' }, + { label: '周三', value: '3' }, + { label: '周四', value: '4' }, + { label: '周五', value: '5' }, + { label: '周六', value: '6' }, + { label: '周日', value: '0' }, +] + +const DAY_OPTIONS = Array.from({ length: 31 }, (_, i) => ({ + label: `${i + 1} 日`, + value: String(i + 1), +})) -// 将 cron 表达式转为中文可读描述 +type ScheduleMode = 'daily' | 'weekly' | 'monthly' | 'interval' + +// 将 cron 表达式转为自然语言中文描述 function describeCron(expr: string): string { const parts = expr.trim().split(/\s+/) if (parts.length !== 5) return '' - const [minute, hour, day, month, week] = parts + const [minute, hour, day, _month, week] = parts + + // 每 N 分钟 + if (minute.includes('/') && hour === '*' && day === '*' && week === '*') { + return `每 ${minute.split('/')[1]} 分钟执行一次` + } + // 每 N 小时 + if (minute !== '*' && hour.includes('/') && day === '*' && week === '*') { + return `每 ${hour.split('/')[1]} 小时执行一次(在第 ${minute} 分)` + } + // 每小时 + if (minute !== '*' && hour === '*' && day === '*' && week === '*') { + return `每小时的第 ${minute} 分执行` + } - const segments: string[] = [] + const hh = hour.padStart(2, '0') + const mm = minute.padStart(2, '0') + const time = `${hh}:${mm}` - // 月 - if (month !== '*') segments.push(`${month} 月`) - // 日 - if (day !== '*') segments.push(`${day} 日`) - // 周 - if (week !== '*') { + // 每周某天 + if (day === '*' && week !== '*') { const weekNames: Record = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' } - const weekDesc = week.split(',').map((w) => weekNames[w] || w).join('、') - segments.push(`星期${weekDesc}`) + const days = week.split(',').map((w) => `周${weekNames[w] || w}`).join('、') + return `每${days} ${time} 执行` } - // 小时 - if (hour.includes('/')) { - segments.push(`每 ${hour.split('/')[1]} 小时`) - } else if (hour !== '*') { - segments.push(`${hour.padStart(2, '0')} 时`) + // 每月某日 + if (day !== '*' && week === '*') { + return `每月 ${day} 日 ${time} 执行` } - // 分钟 - if (minute.includes('/')) { - segments.push(`每 ${minute.split('/')[1]} 分钟`) - } else if (minute !== '*') { - segments.push(`${minute.padStart(2, '0')} 分`) - } else if (hour !== '*' && !hour.includes('/')) { - segments.push('00 分') + // 每天 + if (day === '*' && week === '*' && hour !== '*' && !hour.includes('/')) { + return `每天 ${time} 执行` } - if (segments.length === 0) return '每分钟执行' - return segments.join(' ') + ' 执行' + return '' } -function generateOptions(min: number, max: number) { - return Array.from({ length: max - min + 1 }, (_, i) => ({ - label: String(i + min), - value: String(i + min), - })) -} - -const MINUTES_OPTIONS = generateOptions(0, 59) -const HOURS_OPTIONS = generateOptions(0, 23) -const DAYS_OPTIONS = generateOptions(1, 31) -const MONTHS_OPTIONS = generateOptions(1, 12) -const WEEKS_OPTIONS = [ - { label: '星期日', value: '0' }, - { label: '星期一', value: '1' }, - { label: '星期二', value: '2' }, - { label: '星期三', value: '3' }, - { label: '星期四', value: '4' }, - { label: '星期五', value: '5' }, - { label: '星期六', value: '6' }, -] - export function CronInput({ value, onChange }: CronInputProps) { - const [internalValue, setInternalValue] = useState(value || DEFAULT_CRON) + const [cronExpr, setCronExpr] = useState(value || DEFAULT_CRON) const [isAdvanced, setIsAdvanced] = useState(false) - const [state, setState] = useState(parseCron(internalValue)) + const [showCustom, setShowCustom] = useState(false) - // Sync prop to internal state + // 自定义模式的状态 + const [mode, setMode] = useState('daily') + const [customHour, setCustomHour] = useState('2') + const [customMinute, setCustomMinute] = useState('0') + const [customWeekdays, setCustomWeekdays] = useState(['0']) + const [customDay, setCustomDay] = useState('1') + const [customInterval, setCustomInterval] = useState('6') + + // 从 prop 同步 useEffect(() => { - if (value !== undefined && value !== internalValue) { - setInternalValue(value || DEFAULT_CRON) - if (!isAdvanced) { - setState(parseCron(value || DEFAULT_CRON)) - } + if (value !== undefined && value !== cronExpr) { + setCronExpr(value || DEFAULT_CRON) } - }, [value, isAdvanced, internalValue]) + }, [value]) - const description = useMemo(() => describeCron(internalValue), [internalValue]) + const description = useMemo(() => describeCron(cronExpr), [cronExpr]) + const isPreset = PRESETS.some((p) => p.value === cronExpr) - const notifyChange = (nextValue: string) => { - setInternalValue(nextValue) - if (onChange) { - onChange(nextValue) - } + const emit = (expr: string) => { + setCronExpr(expr) + onChange?.(expr) } - const handleStateChange = (part: CronPart, val: string) => { - const nextState = { ...state, [part]: val } - setState(nextState) - notifyChange(stringifyCron(nextState)) - } - - const handlePreset = (cronExpr: string) => { - setInternalValue(cronExpr) - setState(parseCron(cronExpr)) - if (onChange) onChange(cronExpr) + // 从自定义选择器构建 cron + const buildCustomCron = ( + m: ScheduleMode, + h: string, + min: string, + weekdays: string[], + day: string, + interval: string, + ) => { + switch (m) { + case 'daily': + return `${min} ${h} * * *` + case 'weekly': + return `${min} ${h} * * ${weekdays.sort().join(',') || '0'}` + case 'monthly': + return `${min} ${h} ${day} * *` + case 'interval': + return `0 */${interval} * * *` + default: + return DEFAULT_CRON + } } - const renderPartTab = ( - part: CronPart, - title: string, - options: { label: string; value: string }[], - allowAnyVal = '*', - ) => { - const currentVal = state[part] - const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?' - const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-') + const handleCustomChange = (updates: { + mode?: ScheduleMode + hour?: string + minute?: string + weekdays?: string[] + day?: string + interval?: string + }) => { + const m = updates.mode ?? mode + const h = updates.hour ?? customHour + const min = updates.minute ?? customMinute + const w = updates.weekdays ?? customWeekdays + const d = updates.day ?? customDay + const iv = updates.interval ?? customInterval - const type = isAny ? 'any' : 'specific' - const specificValues = isSpecific ? currentVal.split(',') : [] + if (updates.mode !== undefined) setMode(m) + if (updates.hour !== undefined) setCustomHour(h) + if (updates.minute !== undefined) setCustomMinute(min) + if (updates.weekdays !== undefined) setCustomWeekdays(w) + if (updates.day !== undefined) setCustomDay(d) + if (updates.interval !== undefined) setCustomInterval(iv) - return ( -
- { - if (val === 'any') { - handleStateChange(part, allowAnyVal) - } else { - handleStateChange(part, options[0].value) - } - }} - > - - 通配 ({allowAnyVal}) - 任意{title} - - - 指定{title} - - - - {type === 'specific' && ( -
- { - setInternalValue(val) - if (isAdvanced && onChange) { - onChange(val) - } + if (isAdvanced) emit(val) }} - readOnly={!isAdvanced} - style={{ width: 240, fontFamily: 'monospace' }} - placeholder="* * * * *" /> - - 高级模式 - { - setIsAdvanced(checked) - if (!checked) { - setState(parseCron(internalValue)) - notifyChange(stringifyCron(parseCron(internalValue))) - } - }} - /> - + {description && ( + {description} + )} +
+ + 手动输入 + { + setIsAdvanced(checked) + setShowCustom(false) + if (!checked) { + setCronExpr(cronExpr) + } + }} + /> + +
- {/* 中文可读描述 */} - {description && ( - - {description} - - )} + {/* 自定义选择器 */} + {showCustom && !isAdvanced && ( +
+ + + + + + - {!isAdvanced && ( - - - {renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')} - - - {renderPartTab('hour', '小时', HOURS_OPTIONS, '*')} - - - {renderPartTab('day', '日', DAYS_OPTIONS, '*')} - - - {renderPartTab('month', '月', MONTHS_OPTIONS, '*')} - - - {renderPartTab('week', '周', WEEKS_OPTIONS, '*')} - - + {mode === 'interval' ? ( + + + handleCustomChange({ day: val })} + /> + +
+ )} + + 执行时间 + handleCustomChange({ minute: val })} + /> + + + )} +
)} )