mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-13 07:19:46 +08:00
1. 新增 8 个常用预设按钮(每天 02:00、每 6 小时、每周日、每月 1 日等), 一键设置无需逐个 Tab 操作 2. 新增中文可读描述(如 "02 时 00 分 执行"),实时显示在表达式下方 3. 选中的预设按钮高亮显示
277 lines
8.5 KiB
TypeScript
277 lines
8.5 KiB
TypeScript
import { Button, Input, Space, Switch, Tabs, Typography, Radio, Select } from '@arco-design/web-react'
|
|
import { useEffect, useMemo, useState } from 'react'
|
|
|
|
export interface CronInputProps {
|
|
value?: string
|
|
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 PRESETS = [
|
|
{ label: '每天 02:00', value: '0 2 * * *' },
|
|
{ label: '每天 00:00', value: '0 0 * * *' },
|
|
{ label: '每 6 小时', value: '0 */6 * * *' },
|
|
{ label: '每 12 小时', value: '0 */12 * * *' },
|
|
{ label: '每周日 03:00', value: '0 3 * * 0' },
|
|
{ label: '每月 1 日 02:00', value: '0 2 1 * *' },
|
|
{ label: '每 30 分钟', value: '*/30 * * * *' },
|
|
{ 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] || '*',
|
|
}
|
|
}
|
|
|
|
function stringifyCron(state: CronState): string {
|
|
return `${state.minute} ${state.hour} ${state.day} ${state.month} ${state.week}`
|
|
}
|
|
|
|
// 将 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 segments: string[] = []
|
|
|
|
// 月
|
|
if (month !== '*') segments.push(`${month} 月`)
|
|
// 日
|
|
if (day !== '*') segments.push(`${day} 日`)
|
|
// 周
|
|
if (week !== '*') {
|
|
const weekNames: Record<string, string> = { '0': '日', '1': '一', '2': '二', '3': '三', '4': '四', '5': '五', '6': '六', '7': '日' }
|
|
const weekDesc = week.split(',').map((w) => weekNames[w] || w).join('、')
|
|
segments.push(`星期${weekDesc}`)
|
|
}
|
|
// 小时
|
|
if (hour.includes('/')) {
|
|
segments.push(`每 ${hour.split('/')[1]} 小时`)
|
|
} else if (hour !== '*') {
|
|
segments.push(`${hour.padStart(2, '0')} 时`)
|
|
}
|
|
// 分钟
|
|
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 (segments.length === 0) return '每分钟执行'
|
|
return segments.join(' ') + ' 执行'
|
|
}
|
|
|
|
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 [isAdvanced, setIsAdvanced] = useState(false)
|
|
const [state, setState] = useState<CronState>(parseCron(internalValue))
|
|
|
|
// Sync prop to internal state
|
|
useEffect(() => {
|
|
if (value !== undefined && value !== internalValue) {
|
|
setInternalValue(value || DEFAULT_CRON)
|
|
if (!isAdvanced) {
|
|
setState(parseCron(value || DEFAULT_CRON))
|
|
}
|
|
}
|
|
}, [value, isAdvanced, internalValue])
|
|
|
|
const description = useMemo(() => describeCron(internalValue), [internalValue])
|
|
|
|
const notifyChange = (nextValue: string) => {
|
|
setInternalValue(nextValue)
|
|
if (onChange) {
|
|
onChange(nextValue)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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 type = isAny ? 'any' : 'specific'
|
|
const specificValues = isSpecific ? currentVal.split(',') : []
|
|
|
|
return (
|
|
<div style={{ padding: '16px 0' }}>
|
|
<Radio.Group
|
|
direction="vertical"
|
|
value={type}
|
|
onChange={(val) => {
|
|
if (val === 'any') {
|
|
handleStateChange(part, allowAnyVal)
|
|
} else {
|
|
handleStateChange(part, options[0].value)
|
|
}
|
|
}}
|
|
>
|
|
<Radio value="any">
|
|
<Typography.Text>通配 ({allowAnyVal}) - 任意{title}</Typography.Text>
|
|
</Radio>
|
|
<Radio value="specific">
|
|
<Typography.Text>指定{title}</Typography.Text>
|
|
</Radio>
|
|
</Radio.Group>
|
|
|
|
{type === 'specific' && (
|
|
<div style={{ paddingLeft: 24, marginTop: 12 }}>
|
|
<Select
|
|
mode="multiple"
|
|
placeholder={`请选择${title}`}
|
|
value={specificValues}
|
|
options={options}
|
|
onChange={(vals: string[]) => {
|
|
if (vals.length === 0) {
|
|
handleStateChange(part, allowAnyVal)
|
|
} else {
|
|
const sorted = [...vals].sort((a, b) => Number(a) - Number(b))
|
|
handleStateChange(part, sorted.join(','))
|
|
}
|
|
}}
|
|
style={{ width: '100%', maxWidth: 400 }}
|
|
allowClear
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="cron-input-container">
|
|
{/* 常用预设 */}
|
|
<div style={{ marginBottom: 12 }}>
|
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 8 }}>常用预设</Typography.Text>
|
|
<Space wrap size="small">
|
|
{PRESETS.map((preset) => (
|
|
<Button
|
|
key={preset.value}
|
|
size="small"
|
|
type={internalValue === preset.value ? 'primary' : 'secondary'}
|
|
onClick={() => handlePreset(preset.value)}
|
|
>
|
|
{preset.label}
|
|
</Button>
|
|
))}
|
|
</Space>
|
|
</div>
|
|
|
|
{/* 表达式 + 可读描述 */}
|
|
<div style={{ marginBottom: 12, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
|
<Input
|
|
value={internalValue}
|
|
onChange={(val) => {
|
|
setInternalValue(val)
|
|
if (isAdvanced && onChange) {
|
|
onChange(val)
|
|
}
|
|
}}
|
|
readOnly={!isAdvanced}
|
|
style={{ width: 240, fontFamily: 'monospace' }}
|
|
placeholder="* * * * *"
|
|
/>
|
|
<Space>
|
|
<Typography.Text type="secondary">高级模式</Typography.Text>
|
|
<Switch
|
|
checked={isAdvanced}
|
|
onChange={(checked) => {
|
|
setIsAdvanced(checked)
|
|
if (!checked) {
|
|
setState(parseCron(internalValue))
|
|
notifyChange(stringifyCron(parseCron(internalValue)))
|
|
}
|
|
}}
|
|
/>
|
|
</Space>
|
|
</div>
|
|
|
|
{/* 中文可读描述 */}
|
|
{description && (
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, marginTop: 0 }}>
|
|
{description}
|
|
</Typography.Paragraph>
|
|
)}
|
|
|
|
{!isAdvanced && (
|
|
<Tabs type="card-gutter" size="small">
|
|
<Tabs.TabPane key="minute" title="分钟">
|
|
{renderPartTab('minute', '分钟', MINUTES_OPTIONS, '*')}
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane key="hour" title="小时">
|
|
{renderPartTab('hour', '小时', HOURS_OPTIONS, '*')}
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane key="day" title="日">
|
|
{renderPartTab('day', '日', DAYS_OPTIONS, '*')}
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane key="month" title="月">
|
|
{renderPartTab('month', '月', MONTHS_OPTIONS, '*')}
|
|
</Tabs.TabPane>
|
|
<Tabs.TabPane key="week" title="周">
|
|
{renderPartTab('week', '周', WEEKS_OPTIONS, '*')}
|
|
</Tabs.TabPane>
|
|
</Tabs>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|