mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-05-07 04:12:41 +08:00
优化: 重新设计 Cron 编辑器交互体验
核心问题:预设选中后下方 Tab 编辑器仍展开显示混乱的技术细节。 重新设计为三层交互: 1. 预设按钮(一键选择常见场景,选中高亮,无多余 UI) 2. 自定义选择器(每天/每周/每月/间隔四种模式,直观的时间选择器 和星期按钮,无需理解 cron 语法) 3. 手动输入(高级用户直接编辑 cron 表达式) 同时优化中文描述为自然语言("每天 02:00 执行" 替代 "02 时 00 分 执行")
This commit is contained in:
@@ -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),
|
||||
}))
|
||||
|
||||
// 将 cron 表达式转为中文可读描述
|
||||
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),
|
||||
}))
|
||||
|
||||
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
|
||||
|
||||
const segments: string[] = []
|
||||
// 每 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} 分执行`
|
||||
}
|
||||
|
||||
// 月
|
||||
if (month !== '*') segments.push(`${month} 月`)
|
||||
// 日
|
||||
if (day !== '*') segments.push(`${day} 日`)
|
||||
// 周
|
||||
if (week !== '*') {
|
||||
const hh = hour.padStart(2, '0')
|
||||
const mm = minute.padStart(2, '0')
|
||||
const time = `${hh}:${mm}`
|
||||
|
||||
// 每周某天
|
||||
if (day === '*' && 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}`)
|
||||
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<CronState>(parseCron(internalValue))
|
||||
const [showCustom, setShowCustom] = useState(false)
|
||||
|
||||
// Sync prop to internal state
|
||||
// 自定义模式的状态
|
||||
const [mode, setMode] = useState<ScheduleMode>('daily')
|
||||
const [customHour, setCustomHour] = useState('2')
|
||||
const [customMinute, setCustomMinute] = useState('0')
|
||||
const [customWeekdays, setCustomWeekdays] = useState<string[]>(['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)
|
||||
}
|
||||
|
||||
const renderPartTab = (
|
||||
part: CronPart,
|
||||
title: string,
|
||||
options: { label: string; value: string }[],
|
||||
allowAnyVal = '*',
|
||||
// 从自定义选择器构建 cron
|
||||
const buildCustomCron = (
|
||||
m: ScheduleMode,
|
||||
h: string,
|
||||
min: string,
|
||||
weekdays: string[],
|
||||
day: string,
|
||||
interval: string,
|
||||
) => {
|
||||
const currentVal = state[part]
|
||||
const isAny = currentVal === allowAnyVal || currentVal === '*' || currentVal === '?'
|
||||
const isSpecific = !isAny && !currentVal.includes('/') && !currentVal.includes('-')
|
||||
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 type = isAny ? 'any' : 'specific'
|
||||
const specificValues = isSpecific ? currentVal.split(',') : []
|
||||
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
|
||||
|
||||
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>
|
||||
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)
|
||||
|
||||
{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>
|
||||
)
|
||||
emit(buildCustomCron(m, h, min, w, d, iv))
|
||||
}
|
||||
|
||||
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)))
|
||||
}
|
||||
<div>
|
||||
{/* 预设按钮 */}
|
||||
<Space wrap size="small" style={{ marginBottom: 12 }}>
|
||||
{PRESETS.map((preset) => (
|
||||
<Button
|
||||
key={preset.value}
|
||||
size="small"
|
||||
type={cronExpr === preset.value ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
emit(preset.value)
|
||||
setShowCustom(false)
|
||||
setIsAdvanced(false)
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
size="small"
|
||||
type={!isPreset && !isAdvanced ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
setShowCustom(true)
|
||||
setIsAdvanced(false)
|
||||
}}
|
||||
>
|
||||
自定义...
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{/* 中文描述 + cron 表达式 */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 8 }}>
|
||||
<Input
|
||||
value={cronExpr}
|
||||
readOnly={!isAdvanced}
|
||||
style={{ width: 180, fontFamily: 'monospace', fontSize: 13 }}
|
||||
placeholder="0 2 * * *"
|
||||
onChange={(val) => {
|
||||
if (isAdvanced) emit(val)
|
||||
}}
|
||||
/>
|
||||
{description && (
|
||||
<Typography.Text type="secondary">{description}</Typography.Text>
|
||||
)}
|
||||
<div style={{ marginLeft: 'auto' }}>
|
||||
<Space size="mini">
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>手动输入</Typography.Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={isAdvanced}
|
||||
onChange={(checked) => {
|
||||
setIsAdvanced(checked)
|
||||
setShowCustom(false)
|
||||
if (!checked) {
|
||||
setCronExpr(cronExpr)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 中文可读描述 */}
|
||||
{description && (
|
||||
<Typography.Paragraph type="secondary" style={{ marginBottom: 12, marginTop: 0 }}>
|
||||
{description}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
{/* 自定义选择器 */}
|
||||
{showCustom && !isAdvanced && (
|
||||
<div style={{ padding: '12px 16px', background: 'var(--color-fill-1)', borderRadius: 6 }}>
|
||||
<Space size="large" style={{ marginBottom: 12 }}>
|
||||
<Button size="small" type={mode === 'daily' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'daily' })}>
|
||||
每天
|
||||
</Button>
|
||||
<Button size="small" type={mode === 'weekly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'weekly' })}>
|
||||
每周
|
||||
</Button>
|
||||
<Button size="small" type={mode === 'monthly' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'monthly' })}>
|
||||
每月
|
||||
</Button>
|
||||
<Button size="small" type={mode === 'interval' ? 'primary' : 'text'} onClick={() => handleCustomChange({ mode: 'interval' })}>
|
||||
间隔
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{!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>
|
||||
{mode === 'interval' ? (
|
||||
<Space align="center">
|
||||
<Typography.Text>每</Typography.Text>
|
||||
<Select
|
||||
size="small"
|
||||
value={customInterval}
|
||||
style={{ width: 80 }}
|
||||
options={[
|
||||
{ label: '1', value: '1' },
|
||||
{ label: '2', value: '2' },
|
||||
{ label: '3', value: '3' },
|
||||
{ label: '4', value: '4' },
|
||||
{ label: '6', value: '6' },
|
||||
{ label: '8', value: '8' },
|
||||
{ label: '12', value: '12' },
|
||||
]}
|
||||
onChange={(val) => handleCustomChange({ interval: val })}
|
||||
/>
|
||||
<Typography.Text>小时执行一次</Typography.Text>
|
||||
</Space>
|
||||
) : (
|
||||
<>
|
||||
{mode === 'weekly' && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Space wrap size="mini">
|
||||
{WEEKDAY_OPTIONS.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
size="mini"
|
||||
type={customWeekdays.includes(opt.value) ? 'primary' : 'secondary'}
|
||||
onClick={() => {
|
||||
const next = customWeekdays.includes(opt.value)
|
||||
? customWeekdays.filter((v) => v !== opt.value)
|
||||
: [...customWeekdays, opt.value]
|
||||
handleCustomChange({ weekdays: next.length > 0 ? next : [opt.value] })
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
{mode === 'monthly' && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Space align="center">
|
||||
<Typography.Text>每月</Typography.Text>
|
||||
<Select
|
||||
size="small"
|
||||
value={customDay}
|
||||
style={{ width: 90 }}
|
||||
options={DAY_OPTIONS}
|
||||
onChange={(val) => handleCustomChange({ day: val })}
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
<Space align="center">
|
||||
<Typography.Text>执行时间</Typography.Text>
|
||||
<Select
|
||||
size="small"
|
||||
value={customHour}
|
||||
style={{ width: 90 }}
|
||||
options={HOUR_OPTIONS}
|
||||
onChange={(val) => handleCustomChange({ hour: val })}
|
||||
/>
|
||||
<Typography.Text>:</Typography.Text>
|
||||
<Select
|
||||
size="small"
|
||||
value={customMinute}
|
||||
style={{ width: 90 }}
|
||||
options={MINUTE_OPTIONS}
|
||||
onChange={(val) => handleCustomChange({ minute: val })}
|
||||
/>
|
||||
</Space>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user