feat(components): 添加 CronPicker 组件

- 实现了一个功能完善的 Cron 表达式选择器组件- 包含图形化配置和 Cron 表达式两种输入方式
- 支持分钟、小时、日期、星期、月份等配置
- 提供预设常用配置和错误提示功能
-集成了 cron-parser 和 cronstrue 库进行表达式解析和描述
master
钟良源 5 months ago
parent fbad3337cf
commit 97417a3c49

@ -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;
}

@ -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<CronPickerProps> = ({ value, onChange, style, className }) => {
const [mode, setMode] = useState<'visual' | 'expression'>('visual');
const [cron, setCron] = useState<string>(value || '0 0 0 * * ?');
const [error, setError] = useState<string>('');
// 图形化配置的状态
const [minuteType, setMinuteType] = useState<'every' | 'interval'>('interval');
const [minutesInterval, setMinutesInterval] = useState<number>(5);
const [hourType, setHourType] = useState<'every' | 'at'>('at');
const [hourlyAt, setHourlyAt] = useState<number>(12);
const [dailyType, setDailyType] = useState<'every' | 'weekday' | 'specific'>('every');
const [specificDay, setSpecificDay] = useState<number>(1);
const [selectedWeekdays, setSelectedWeekdays] = useState<string[]>(['MON']);
const [selectedMonths, setSelectedMonths] = useState<string[]>(['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<HTMLInputElement>) => {
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 (
<div style={style} className={`${styles.container} ${className || ''}`}>
<Tabs onChange={handleTabChange} type="rounded" size="small" className={styles.tabs}>
<TabPane key="visual" title="图形化配置" />
<TabPane key="expression" title="Cron 表达式" />
</Tabs>
{mode === 'visual' && (
<div>
<Form.Item label="分钟" className={styles.formItem}>
<Radio.Group value={minuteType} onChange={handleMinuteTypeChange} style={{ display: 'flex' }}>
<Space size="large">
<Radio value="every"></Radio>
<span className={styles.inlineGroup}>
<Radio value="interval"></Radio>
<InputNumber
value={minutesInterval}
onChange={setMinutesInterval}
min={1}
max={59}
size="small"
className={styles.inputNumber}
/>
<span></span>
</span>
</Space>
</Radio.Group>
</Form.Item>
<Form.Item label="小时" className={styles.formItem}>
<Radio.Group value={hourType} onChange={handleHourTypeChange} style={{ display: 'flex' }}>
<Space size="large">
<Radio value="every"></Radio>
<span className={styles.inlineGroup}>
<Radio value="at"></Radio>
<InputNumber
value={hourlyAt}
onChange={setHourlyAt}
min={0}
max={23}
size="small"
className={styles.inputNumber}
/>
<span></span>
</span>
</Space>
</Radio.Group>
</Form.Item>
<Form.Item label="执行日" className={styles.formItem}>
<Radio.Group value={dailyType} onChange={handleDailyTypeChange} style={{ display: 'flex' }}>
<Space size="large">
<Radio value="every"></Radio>
<Radio value="weekday"></Radio>
<span className={styles.inlineGroup}>
<Radio value="specific"></Radio>
<InputNumber
value={specificDay}
onChange={setSpecificDay}
min={1}
max={31}
size="small"
className={styles.inputNumber}
/>
<span></span>
</span>
</Space>
</Radio.Group>
</Form.Item>
<Form.Item label="星期" className={styles.formItem}>
<div className={styles.inlineGroup}>
{['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN'].map(day => (
<Button
key={day}
type={selectedWeekdays.includes(day) ? 'primary' : 'secondary'}
size="small"
className={styles.weekButton}
onClick={() => {
setSelectedWeekdays(prev =>
prev.includes(day) ? prev.filter(d => d !== day) : [...prev, day]
);
}}
>
{dayMap[day]}
</Button>
))}
</div>
</Form.Item>
<Space wrap size="small" className={styles.presetsContainer}>
{PRESETS.map(p => (
<Button key={p.value} type="text" size="small" onClick={() => updateCron(p.value)}>
{p.label}
</Button>
))}
</Space>
</div>
)}
{mode === 'expression' && (
<div>
<label style={{ display: 'block', marginBottom: 6, fontSize: 14, color: '#333' }}>
Cron
</label>
<input
type="text"
value={currentCron}
onChange={handleExpressionChange}
placeholder="0 0 12 * * ?"
className={`${styles.expressionInput} ${error ? styles.error : ''}`}
/>
{error && <div className={styles.errorMessage}>{error}</div>}
</div>
)}
<div className={styles.previewContainer}>
<Text type="secondary" style={{ fontSize: 13, display: 'block', marginBottom: 4 }}>
{getHumanReadable()}
</Text>
<Text type="primary" style={{ fontSize: 13, display: 'block' }}>
{getNextTime()}
</Text>
<div style={{ marginTop: 8 }}>
<Text type="secondary" style={{ fontSize: 13, display: 'block' }}>
</Text>
<ul style={{ fontSize: 12, margin: '4px 0 0 20px', padding: 0 }}>
{getNextFiveTimes().map((time, index) => (
<li key={index} style={{ marginBottom: 2 }}>
<Text type="primary" style={{ fontSize: 12 }}>
{time}
</Text>
</li>
))}
</ul>
</div>
</div>
</div>
);
};
export default CronPicker;
Loading…
Cancel
Save