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