From 97417a3c49d401d12d3c269aa1c5b5480ce318a9 Mon Sep 17 00:00:00 2001 From: ZLY Date: Tue, 2 Sep 2025 11:34:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(components):=20=E6=B7=BB=E5=8A=A0=20CronPi?= =?UTF-8?q?cker=20=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 实现了一个功能完善的 Cron 表达式选择器组件- 包含图形化配置和 Cron 表达式两种输入方式 - 支持分钟、小时、日期、星期、月份等配置 - 提供预设常用配置和错误提示功能 -集成了 cron-parser 和 cronstrue 库进行表达式解析和描述 --- src/components/CronPicker/index.module.less | 70 ++++ src/components/CronPicker/index.tsx | 420 ++++++++++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 src/components/CronPicker/index.module.less create mode 100644 src/components/CronPicker/index.tsx diff --git a/src/components/CronPicker/index.module.less b/src/components/CronPicker/index.module.less new file mode 100644 index 0000000..3cb3111 --- /dev/null +++ b/src/components/CronPicker/index.module.less @@ -0,0 +1,70 @@ +.container { + padding: 16px; + border: 1px solid #e0e0e0; + border-radius: 8px; +} + +.tabs { + margin-bottom: 16px; +} + +.formItem { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +.formItem label { + width: 70px; // 增加标签宽度 + margin-right: 16px; // 增加右边距 + font-size: 14px; + color: #333; + flex-shrink: 0; // 防止标签被压缩 +} + +.inlineGroup { + display: flex; + align-items: center; + flex-wrap: wrap; // 允许换行 + gap: 8px; // 使用gap代替margin,更方便控制间距 +} + +.inputNumber { + width: 80px; +} + +.weekButton { + min-width: 40px; +} + +.presetsContainer { + margin-top: 16px; + margin-bottom: 16px; + gap: 8px; // 使用gap控制按钮间距 +} + +.expressionInput { + width: 100%; + padding: 8px; + border: 1px solid #d9d9d9; + border-radius: 4px; + font-size: 14px; + box-sizing: border-box; +} + +.expressionInput.error { + border-color: #f5222d; +} + +.errorMessage { + color: #f5222d; + font-size: 12px; + margin-top: 4px; +} + +.previewContainer { + margin-top: 16px; + padding: 12px; + background-color: #f8f8f8; + border-radius: 4px; +} \ No newline at end of file diff --git a/src/components/CronPicker/index.tsx b/src/components/CronPicker/index.tsx new file mode 100644 index 0000000..2614a0f --- /dev/null +++ b/src/components/CronPicker/index.tsx @@ -0,0 +1,420 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Form, + Tabs, + Radio, + InputNumber, + Button, + Space, + Typography +} from '@arco-design/web-react'; +import { CronExpressionParser } from 'cron-parser'; +import cronstrue from 'cronstrue/i18n'; +import styles from './index.module.less'; + +const { TabPane } = Tabs; +const { Text } = Typography; + +interface CronPickerProps { + value?: string; + onChange?: (value: string) => void; + style?: React.CSSProperties; + className?: string; +} + +const PRESETS = [ + { label: '每分钟', value: '0 * * * * ?' }, + { label: '每5分钟', value: '0 0/5 * * * ?' }, + { label: '每小时', value: '0 0 * * * ?' }, + { label: '每天0点', value: '0 0 0 * * ?' }, + { label: '每天中午', value: '0 0 12 * * ?' }, + { label: '每周一9点', value: '0 0 9 ? * MON' }, + { label: '每月1号0点', value: '0 0 0 1 * ?' } +]; + +const dayMap = { + 'MON': '周一', + 'TUE': '周二', + 'WED': '周三', + 'THU': '周四', + 'FRI': '周五', + 'SAT': '周六', + 'SUN': '周日' +}; + +const CronPicker: React.FC = ({ value, onChange, style, className }) => { + const [mode, setMode] = useState<'visual' | 'expression'>('visual'); + const [cron, setCron] = useState(value || '0 0 0 * * ?'); + const [error, setError] = useState(''); + + // 图形化配置的状态 + const [minuteType, setMinuteType] = useState<'every' | 'interval'>('interval'); + const [minutesInterval, setMinutesInterval] = useState(5); + const [hourType, setHourType] = useState<'every' | 'at'>('at'); + const [hourlyAt, setHourlyAt] = useState(12); + const [dailyType, setDailyType] = useState<'every' | 'weekday' | 'specific'>('every'); + const [specificDay, setSpecificDay] = useState(1); + const [selectedWeekdays, setSelectedWeekdays] = useState(['MON']); + const [selectedMonths, setSelectedMonths] = useState(['1']); + + // 初始化 + useEffect(() => { + if (value !== undefined && value !== cron) { + setCron(value); + // 当外部value变化时,解析并更新图形化配置的状态 + parseCronExpression(value); + } + }, [value]); + + // 构建cron表达式 + const generateCron = () => { + let min = '*'; + let hour = '*'; + let dom = '*'; + let dow = '?'; + const sec = '0'; + + // 分钟逻辑 + if (minuteType === 'every') { + min = '*'; + } + else if (minuteType === 'interval') { + min = `0/${minutesInterval}`; + } + + // 小时逻辑 + if (hourType === 'every') { + hour = '*'; + } + else if (hourType === 'at') { + hour = hourlyAt.toString(); + } + + // 日期逻辑 + if (dailyType === 'every') { + dom = '*'; + dow = '?'; + } + else if (dailyType === 'weekday') { + dom = '?'; + dow = 'MON-FRI'; + } + else if (dailyType === 'specific') { + dom = specificDay.toString(); + dow = '?'; + } + + const mon = selectedMonths.length < 12 ? selectedMonths.join(',') : '*'; + const newCron = `${sec} ${min} ${hour} ${dom} ${mon} ${dow}`; + return newCron; + // validateAndSet(newCron); + }; + + const currentCron = useMemo(() => { + if (mode === 'visual') { + return generateCron(); + } + return cron; + }, [mode, minuteType, minutesInterval, hourType, hourlyAt, dailyType, specificDay, selectedWeekdays, selectedMonths, cron]); + + // 解析Cron表达式并更新图形化配置的状态 + const parseCronExpression = (cronExpression: string) => { + // 简单解析Cron表达式,更新图形化配置的状态 + const parts = cronExpression.split(' '); + if (parts.length >= 6) { + const [, minute, hour, dayOfMonth, month, dayOfWeek] = parts; + + // 解析分钟部分 + if (minute === '*') { + setMinuteType('every'); + } + else if (minute.startsWith('0/')) { + setMinuteType('interval'); + const interval = parseInt(minute.substring(2)); + if (!isNaN(interval)) { + setMinutesInterval(interval); + } + } + + // 解析小时部分 + if (hour === '*') { + setHourType('every'); + } + else { + const hourNum = parseInt(hour); + if (!isNaN(hourNum)) { + setHourType('at'); + setHourlyAt(hourNum); + } + } + + // 解析日期部分 + if (dayOfMonth === '*' && dayOfWeek === '?') { + setDailyType('every'); + } + else if (dayOfMonth === '?' && dayOfWeek === 'MON-FRI') { + setDailyType('weekday'); + } + else if (dayOfMonth !== '*' && dayOfMonth !== '?' && dayOfWeek === '?') { + setDailyType('specific'); + const dayNum = parseInt(dayOfMonth); + if (!isNaN(dayNum)) { + setSpecificDay(dayNum); + } + } + + // 解析星期部分 + if (dayOfWeek !== '?' && dayOfWeek !== '*') { + if (dayOfWeek.includes('-') || dayOfWeek.includes(',')) { + // 简化处理,实际可以更精确解析 + setSelectedWeekdays(dayOfWeek.split(/[-,]/)); + } + else { + setSelectedWeekdays([dayOfWeek]); + } + } + + // 解析月份部分 + if (month !== '*' && month.includes(',')) { + setSelectedMonths(month.split(',')); + } + else if (month !== '*') { + setSelectedMonths([month]); + } + } + }; + + const updateCron = (newCron: string) => { + setCron(newCron); + onChange?.(newCron); + setError(''); + // 更新图形化配置的状态 + parseCronExpression(newCron); + }; + + const validateAndSet = (cronStr: string) => { + try { + CronExpressionParser.parse(cronStr); + updateCron(cronStr); + } catch (e) { + setError('无效的 Cron 表达式'); + } + }; + + const getHumanReadable = (): string => { + try { + return cronstrue.toString(currentCron, { locale: 'zh_CN' }); + } catch { + return '表达式无效'; + } + }; + + const getNextTime = (): string => { + try { + console.log('currentCron:', currentCron); + const interval = CronExpressionParser.parse(currentCron); + const next = interval.next(); + return next?.toDate ? next.toDate().toLocaleString() : '无法计算'; + } catch (e) { + return '无法计算'; + } + }; + + const getNextFiveTimes = (): string[] => { + try { + const interval = CronExpressionParser.parse(currentCron); + const times = []; + for (let i = 0; i < 5; i++) { + const next = interval.next(); + if (next?.toDate) { + times.push(next.toDate().toLocaleString()); + } + else { + times.push('无法计算'); + } + } + return times; + } catch (e) { + return Array(5).fill('无法计算'); + } + }; + + // 添加处理表达式变更的函数 + const handleExpressionChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setCron(value); + try { + CronExpressionParser.parse(value); + onChange?.(value); + setError(''); + } catch (e) { + setError('无效的 Cron 表达式'); + } + }; + + // 切换Tab时更新状态 + const handleTabChange = (key: string) => { + setMode(key as 'visual' | 'expression'); + if (key === 'visual') { + // 切换到图形化配置时,根据当前cron表达式更新状态 + parseCronExpression(currentCron); + const newCron = generateCron(); + validateAndSet(newCron); + } + }; + + const handleMinuteTypeChange = (value: string) => { + setMinuteType(value as 'every' | 'interval'); + }; + + const handleHourTypeChange = (value: string) => { + setHourType(value as 'every' | 'at'); + }; + + const handleDailyTypeChange = (value: string) => { + setDailyType(value as 'every' | 'weekday' | 'specific'); + }; + + return ( +
+ + + + + + {mode === 'visual' && ( +
+ + + + 每分钟 + + + + 分钟 + + + + + + + + + 每小时 + + + + + + + + + + + + + 每天 + 工作日 + + 每月第 + + + + + + + + +
+ {['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'].map(day => ( + + ))} +
+
+ + + {PRESETS.map(p => ( + + ))} + +
+ )} + + {mode === 'expression' && ( +
+ + + {error &&
{error}
} +
+ )} + +
+ + 说明:{getHumanReadable()} + + + 下次执行:{getNextTime()} + +
+ + 接下来五次执行时间: + +
    + {getNextFiveTimes().map((time, index) => ( +
  • + + {time} + +
  • + ))} +
+
+
+
+ ); +}; + +export default CronPicker; \ No newline at end of file