feat(job): 支持可视化生成 CRON 表达式

This commit is contained in:
KAI
2024-10-10 05:52:46 +00:00
committed by Charles7c
parent 4a79040a7f
commit adcb9fed2a
17 changed files with 1271 additions and 20 deletions

View File

@@ -0,0 +1,88 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.unset" v-bind="beforeRadioAttrs">不设置</a-radio>
<span class="tip-info">日和周只能设置其中之一</span>
</div>
<div class="item">
<a-radio :value="TypeEnum.every" v-bind="beforeRadioAttrs">每日</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-input-number v-model="valueRange.start" v-bind="inputNumberAttrs" />
<span> </span>
<a-input-number v-model="valueRange.end" v-bind="inputNumberAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-input-number v-model="valueLoop.start" v-bind="typeLoopAttrs" />
<span>日开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.last" v-bind="beforeRadioAttrs">最后一日</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">指定</a-radio>
<div class="list">
<a-checkbox-group v-model="valueList">
<a-grid :cols="11">
<a-grid-item v-for="i in specifyRange" :key="i">
<a-checkbox :value="i" v-bind="typeSpecifyAttrs">
{{ i }}
</a-checkbox>
</a-grid-item>
</a-grid>
</a-checkbox-group>
</div>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { TypeEnum, useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'DayForm',
props: useFormProps({
defaultValue: '*',
props: {
week: { type: String, default: '?' }
}
}),
emits: useFromEmits(),
setup(props, context) {
const isDisabled = computed(() => {
return (props.week && props.week !== '?') || props.disabled
})
const setup = useFormSetup(props, context, {
defaultValue: '*',
valueWork: 1,
minValue: 1,
maxValue: 31,
valueRange: { start: 1, end: 31 },
valueLoop: { start: 1, interval: 1 },
disabled: isDisabled
})
const typeWorkAttrs = computed(() => ({
disabled: setup.type.value !== TypeEnum.work || props.disabled || isDisabled.value,
...setup.inputNumberAttrs.value
}))
watch(
() => props.week,
() => {
setup.updateValue(isDisabled.value ? '?' : setup.computeValue.value)
}
)
return { ...setup, typeWorkAttrs }
}
})
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.every" v-bind="beforeRadioAttrs">每时</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-input-number v-model="valueRange.start" v-bind="typeRangeAttrs" />
<span> </span>
<a-input-number v-model="valueRange.end" v-bind="typeRangeAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-input-number v-model="valueLoop.start" v-bind="typeLoopAttrs" />
<span>时开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">指定</a-radio>
<div class="list">
<a-checkbox-group v-model="valueList">
<a-grid :cols="12">
<a-grid-item v-for="i in specifyRange" :key="i">
<a-checkbox :value="i" v-bind="typeSpecifyAttrs">
{{ i }}
</a-checkbox>
</a-grid-item>
</a-grid>
</a-checkbox-group>
</div>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'HourForm',
props: useFormProps({
defaultValue: '*'
}),
emits: useFromEmits(),
setup(props, context) {
return useFormSetup(props, context, {
defaultValue: '*',
minValue: 0,
maxValue: 23,
valueRange: { start: 0, end: 23 },
valueLoop: { start: 0, interval: 1 }
})
}
})
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.every" v-bind="beforeRadioAttrs">每分</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-input-number v-model="valueRange.start" v-bind="typeRangeAttrs" />
<span> </span>
<a-input-number v-model="valueRange.end" v-bind="typeRangeAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-input-number v-model="valueLoop.start" v-bind="typeLoopAttrs" />
<span>分开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">指定</a-radio>
<div class="list">
<a-checkbox-group v-model="valueList">
<a-grid :cols="10">
<a-grid-item v-for="i in specifyRange" :key="i">
<a-checkbox :value="i" v-bind="typeSpecifyAttrs">
{{ i }}
</a-checkbox>
</a-grid-item>
</a-grid>
</a-checkbox-group>
</div>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'MinuteForm',
props: useFormProps({
defaultValue: '*'
}),
emits: useFromEmits(),
setup(props, context) {
return useFormSetup(props, context, {
defaultValue: '*',
minValue: 0,
maxValue: 59,
valueRange: { start: 0, end: 59 },
valueLoop: { start: 0, interval: 1 }
})
}
})
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.every" v-bind="beforeRadioAttrs">每月</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-input-number v-model="valueRange.start" v-bind="typeRangeAttrs" />
<span> </span>
<a-input-number v-model="valueRange.end" v-bind="typeRangeAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-input-number v-model="valueLoop.start" v-bind="typeLoopAttrs" />
<span>月开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">指定</a-radio>
<div class="list">
<a-checkbox-group v-model="valueList">
<a-grid :cols="12">
<a-grid-item v-for="i in specifyRange" :key="i">
<a-checkbox :value="i" v-bind="typeSpecifyAttrs">
{{ i }}
</a-checkbox>
</a-grid-item>
</a-grid>
</a-checkbox-group>
</div>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'MonthForm',
props: useFormProps({
defaultValue: '*'
}),
emits: useFromEmits(),
setup(props, context) {
return useFormSetup(props, context, {
defaultValue: '*',
minValue: 1,
maxValue: 12,
valueRange: { start: 1, end: 12 },
valueLoop: { start: 1, interval: 1 }
})
}
})
</script>

View File

@@ -0,0 +1,60 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.every" v-bind="beforeRadioAttrs">每秒</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-input-number v-model="valueRange.start" v-bind="typeRangeAttrs" />
<span> </span>
<a-input-number v-model="valueRange.end" v-bind="typeRangeAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-input-number v-model="valueLoop.start" v-bind="typeLoopAttrs" />
<span>秒开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">指定</a-radio>
<div class="list">
<a-checkbox-group v-model="valueList">
<a-grid :cols="10">
<a-grid-item v-for="i in specifyRange" :key="i">
<a-checkbox :value="i" v-bind="typeSpecifyAttrs">
{{ i }}
</a-checkbox>
</a-grid-item>
</a-grid>
</a-checkbox-group>
</div>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'SecondForm',
props: useFormProps({
defaultValue: '*'
}),
emits: useFromEmits(),
setup(props, context) {
return useFormSetup(props, context, {
defaultValue: '*',
minValue: 0,
maxValue: 59,
valueRange: { start: 0, end: 59 },
valueLoop: { start: 0, interval: 1 }
})
}
})
</script>

View File

@@ -0,0 +1,220 @@
import { computed, reactive, ref, unref, watch } from 'vue'
// 类型定义
export enum TypeEnum {
unset = 'UNSET',
every = 'EVERY',
range = 'RANGE',
loop = 'LOOP',
work = 'WORK',
last = 'LAST',
specify = 'SPECIFY'
}
// 周定义
export const WEEK_MAP: any = {
1: '周日',
2: '周一',
3: '周二',
4: '周三',
5: '周四',
6: '周五',
7: '周六'
}
// use 公共 props
export function useFormProps(options: any) {
const defaultValue = options?.defaultValue ?? '?'
return {
modelValue: {
type: String,
default: defaultValue
},
disabled: {
type: Boolean,
default: false
},
...options?.props
}
}
// use 公共 emits
export function useFromEmits() {
return ['change', 'update:modelValue']
}
// use 公共 setup
export function useFormSetup(props: any, context: any, options: any) {
const { emit } = context
const defaultValue = ref(options?.defaultValue ?? '?')
// 类型
const type = ref(options.defaultType ?? TypeEnum.every)
const valueList = ref<any[]>([])
// 对于不同的类型, 所定义的值也有所不同
const valueRange = reactive(options.valueRange)
const valueLoop = reactive(options.valueLoop)
const valueWork = ref(options.valueWork)
const maxValue = ref(options.maxValue)
const minValue = ref(options.minValue)
// 根据不同的类型计算出的 value
const computeValue = computed(() => {
const valueArray: any[] = []
switch (type.value) {
case TypeEnum.unset:
valueArray.push('?')
break
case TypeEnum.every:
valueArray.push('*')
break
case TypeEnum.range:
valueArray.push(`${valueRange.start}-${valueRange.end}`)
break
case TypeEnum.loop:
valueArray.push(`${valueLoop.start}/${valueLoop.interval}`)
break
case TypeEnum.work:
valueArray.push(`${valueWork.value}W`)
break
case TypeEnum.last:
valueArray.push('L')
break
case TypeEnum.specify:
if (valueList.value.length === 0) {
valueList.value.push(minValue.value)
}
valueArray.push(valueList.value.join(','))
break
default:
valueArray.push(defaultValue.value)
break
}
return valueArray.length > 0 ? valueArray.join('') : defaultValue.value
})
// 指定值范围区间, 介于最小值和最大值之间
const specifyRange = computed(() => {
const range: number[] = []
if (maxValue.value != null) {
for (let i = minValue.value; i <= maxValue.value; i++) {
range.push(i)
}
}
return range
})
// 更新值
const updateValue = (value: any) => {
emit('change', value)
emit('update:modelValue', value)
}
// 解析值
const parseValue = (value: any) => {
if (value === computeValue.value) {
return
}
try {
if (!value || value === defaultValue.value) {
type.value = TypeEnum.every
} else if (value.includes('?')) {
type.value = TypeEnum.unset
} else if (value.includes('-')) {
type.value = TypeEnum.range
const values = value.split('-')
if (values.length >= 2) {
valueRange.start = Number.parseInt(values[0])
valueRange.end = Number.parseInt(values[1])
}
} else if (value.includes('/')) {
type.value = TypeEnum.loop
const values = value.split('/')
if (values.length >= 2) {
valueLoop.start = value[0] === '*' ? 0 : Number.parseInt(values[0])
valueLoop.interval = Number.parseInt(values[1])
}
} else if (value.includes('W')) {
type.value = TypeEnum.work
const values = value.split('W')
if (!values[0] && !Number.isNaN(values[0])) {
valueWork.value = Number.parseInt(values[0])
}
} else if (value.includes('L')) {
type.value = TypeEnum.last
} else if (value.includes(',') || !Number.isNaN(value)) {
type.value = TypeEnum.specify
valueList.value = value.split(',').map((item: any) => Number.parseInt(item))
} else {
type.value = TypeEnum.every
}
} catch (e) {
type.value = TypeEnum.every
}
}
// 更新值
watch(() => props.modelValue, (val) => {
if (val !== computeValue.value) {
parseValue(val)
}
}, { immediate: true })
// 更新值
watch(computeValue, (v) => updateValue(v))
// 单选框属性
const beforeRadioAttrs = computed(() => ({
class: ['choice'],
disabled: props.disabled || unref(options.disabled),
size: 'small'
}))
// 输入框属性
const inputNumberAttrs = computed(() => ({
max: maxValue.value,
min: minValue.value,
precision: 0,
size: 'small',
hideButton: true,
class: 'w60'
}))
// 区间属性
const typeRangeAttrs = computed(() => ({
disabled: type.value !== TypeEnum.range || props.disabled || unref(options.disabled),
...inputNumberAttrs.value
}))
// 间隔属性
const typeLoopAttrs = computed(() => ({
disabled: type.value !== TypeEnum.loop || props.disabled || unref(options.disabled),
...inputNumberAttrs.value
}))
// 指定属性
const typeSpecifyAttrs = computed(() => ({
disabled: type.value !== TypeEnum.specify || props.disabled || unref(options.disabled),
class: ['list-check-item'],
size: 'small'
}))
return {
type,
TypeEnum,
defaultValue,
valueRange,
valueLoop,
valueList,
valueWork,
maxValue,
minValue,
computeValue,
specifyRange,
updateValue,
beforeRadioAttrs,
inputNumberAttrs,
typeRangeAttrs,
typeLoopAttrs,
typeSpecifyAttrs
}
}

View File

@@ -0,0 +1,108 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.unset" v-bind="beforeRadioAttrs">不设置</a-radio>
<span class="tip-info">日和周只能设置其中之一</span>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-select v-model="valueRange.start" v-bind="typeRangeSelectAttrs">
<a-option v-for="item in weekOptions" :key="item.value" :label="item.label" :value="item.value" />
</a-select>
<span></span>
<a-select v-model="valueRange.end" v-bind="typeRangeSelectAttrs">
<a-option v-for="item in weekOptions" :key="item.value" :label="item.label" :value="item.value" />
</a-select>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-select v-model="valueLoop.start" v-bind="typeLoopSelectAttrs">
<a-option v-for="item in weekOptions" :key="item.value" :label="item.label" :value="item.value" />
</a-select>
<span>开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.specify" v-bind="beforeRadioAttrs">指定</a-radio>
<div class="list list-cn">
<a-checkbox-group v-model="valueList">
<template v-for="opt in weekOptions" :key="opt">
<a-checkbox :value="opt.value" v-bind="typeSpecifyAttrs">
{{ opt.label }}
</a-checkbox>
</template>
</a-checkbox-group>
</div>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { TypeEnum, WEEK_MAP, useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'WeekForm',
props: useFormProps({
defaultValue: '?',
props: {
day: { type: String, default: '*' }
}
}),
emits: useFromEmits(),
setup(props, context) {
const disabledChoice = computed(() => {
return (props.day && props.day !== '?') || props.disabled
})
const setup = useFormSetup(props, context, {
defaultType: TypeEnum.unset,
defaultValue: '?',
minValue: 1,
maxValue: 7,
// 0,7表示周日 1表示周一
valueRange: { start: 1, end: 7 },
valueLoop: { start: 2, interval: 1 },
disabled: disabledChoice
})
const weekOptions = computed(() => {
const options: { label: string, value: number }[] = []
for (const weekKey of Object.keys(WEEK_MAP)) {
const weekName: string = WEEK_MAP[weekKey]
options.push({
value: Number.parseInt(weekKey),
label: weekName
})
}
return options
})
const typeRangeSelectAttrs = computed(() => ({
disabled: setup.typeRangeAttrs.value.disabled,
size: 'small',
class: ['w80']
}))
const typeLoopSelectAttrs = computed(() => ({
disabled: setup.typeLoopAttrs.value.disabled,
size: 'small',
class: ['w80']
}))
watch(() => props.day, () => {
setup.updateValue(disabledChoice.value ? '?' : setup.computeValue.value)
})
return {
...setup,
weekOptions,
typeLoopSelectAttrs,
typeRangeSelectAttrs,
WEEK_MAP
}
}
})
</script>

View File

@@ -0,0 +1,46 @@
<template>
<div class="cron-inner-config-list">
<a-radio-group v-model="type">
<div class="item">
<a-radio :value="TypeEnum.every" v-bind="beforeRadioAttrs">每年</a-radio>
</div>
<div class="item">
<a-radio :value="TypeEnum.range" v-bind="beforeRadioAttrs">区间</a-radio>
<span></span>
<a-input-number v-model="valueRange.start" v-bind="typeRangeAttrs" />
<span> </span>
<a-input-number v-model="valueRange.end" v-bind="typeRangeAttrs" />
<span></span>
</div>
<div class="item">
<a-radio :value="TypeEnum.loop" v-bind="beforeRadioAttrs">循环</a-radio>
<span></span>
<a-input-number v-model="valueLoop.start" v-bind="typeLoopAttrs" />
<span>年开始, 间隔</span>
<a-input-number v-model="valueLoop.interval" v-bind="typeLoopAttrs" />
<span></span>
</div>
</a-radio-group>
</div>
</template>
<script lang="ts">
import { useFormProps, useFormSetup, useFromEmits } from './use-mixin'
export default defineComponent({
name: 'YearForm',
props: useFormProps({
defaultValue: '*'
}),
emits: useFromEmits(),
setup(props, context) {
const nowYear = new Date().getFullYear()
return useFormSetup(props, context, {
defaultValue: '*',
minValue: 0,
valueRange: { start: nowYear, end: nowYear + 100 },
valueLoop: { start: nowYear, interval: 1 }
})
}
})
</script>

View File

@@ -0,0 +1,378 @@
<template>
<div class="cron-inner">
<div class="content">
<!-- 设置表单 -->
<a-tabs v-model:active-key="activeKey" size="small">
<!-- -->
<a-tab-pane v-if="!hideSecond" key="second" title="秒">
<SecondForm v-model="second" :disabled="disabled" />
</a-tab-pane>
<!-- -->
<a-tab-pane key="minute" title="分">
<MinuteForm v-model="minute" :disabled="disabled" />
</a-tab-pane>
<!-- -->
<a-tab-pane key="hour" title="时">
<HourForm v-model="hour" :disabled="disabled" />
</a-tab-pane>
<!-- -->
<a-tab-pane key="day" title="日">
<DayForm v-model="day" :week="week" :disabled="disabled" />
</a-tab-pane>
<!-- -->
<a-tab-pane key="month" title="月">
<MonthForm v-model="month" :disabled="disabled" />
</a-tab-pane>
<!-- -->
<a-tab-pane key="week" title="周">
<WeekForm v-model="week" :day="day" :disabled="disabled" />
</a-tab-pane>
<!-- -->
<a-tab-pane v-if="!hideYear && !hideSecond" key="year" title="年">
<YearForm v-model="year" :disabled="disabled" />
</a-tab-pane>
</a-tabs>
<!-- 执行时间预览 -->
<a-row :gutter="8">
<!-- 快捷修改 -->
<a-col :span="18" style="margin-top: 28px">
<a-row :gutter="[12, 12]">
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.second" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'second'"></span>
</template>
</a-input>
</a-col>
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.minute" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'minute'"></span>
</template>
</a-input>
</a-col>
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.hour" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'hour'"></span>
</template>
</a-input>
</a-col>
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.day" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'day'"></span>
</template>
</a-input>
</a-col>
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.month" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'month'"></span>
</template>
</a-input>
</a-col>
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.week" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'week'"></span>
</template>
</a-input>
</a-col>
<!-- -->
<a-col :span="8">
<a-input v-model="cronInputs.year" @change="onInputChange">
<template #prepend>
<span class="allow-click" @click="activeKey = 'year'"></span>
</template>
</a-input>
</a-col>
<!-- 表达式 -->
<a-col :span="16">
<a-input v-model="cronInputs.cron"
:placeholder="placeholder"
@change="onInputCronChange">
<template #prepend>
<span class="allow-click">表达式</span>
</template>
</a-input>
</a-col>
</a-row>
</a-col>
<!-- 执行时间 -->
<a-col :span="6">
<div class="preview-times usn">近五次执行时间 (不解析年)</div>
<a-textarea v-model="previewTimes" :auto-size="{ minRows: 5, maxRows: 5 }" />
</a-col>
</a-row>
</div>
</div>
</template>
<script lang="ts">
</script>
<script lang="ts" setup>
import { useDebounceFn } from '@vueuse/core'
import CronParser from 'cron-parser'
import SecondForm from '@/components/GenCron/CronForm/component/second-form.vue'
import MinuteForm from '@/components/GenCron/CronForm/component/minute-form.vue'
import HourForm from '@/components/GenCron/CronForm/component/hour-form.vue'
import DayForm from '@/components/GenCron/CronForm/component/day-form.vue'
import MonthForm from '@/components/GenCron/CronForm/component/month-form.vue'
import WeekForm from '@/components/GenCron/CronForm/component/week-form.vue'
import YearForm from '@/components/GenCron/CronForm/component/year-form.vue'
import { dateFormat } from '@/utils'
import type { CronPropType } from '@/components/GenCron/CronForm/type'
const props = withDefaults(defineProps<Partial<CronPropType>>(), {
disabled: false,
hideSecond: false,
hideYear: false,
placeholder: '请输入 Cron 表达式'
})
const emit = defineEmits(['change', 'update:modelValue'])
const activeKey = ref(props.hideSecond ? 'minute' : 'second')
const second = ref('*')
const minute = ref('*')
const hour = ref('*')
const day = ref('*')
const month = ref('*')
const week = ref('?')
const year = ref('*')
const cronInputs = reactive({
second: '',
minute: '',
hour: '',
day: '',
month: '',
week: '',
year: '',
cron: ''
})
const previewTimes = ref('执行预览')
// cron 表达式
const cronExpression = computed(() => {
const result: string[] = []
if (!props.hideSecond) {
result.push(second.value ? second.value : '*')
}
result.push(minute.value ? minute.value : '*')
result.push(hour.value ? hour.value : '*')
result.push(day.value ? day.value : '*')
result.push(month.value ? month.value : '*')
result.push(week.value ? week.value : '?')
if (!props.hideYear && !props.hideSecond) {
result.push(year.value ? year.value : '*')
}
return result.join(' ')
})
// 不含年的 cron 表达式
const expressionNoYear = (corn: string) => {
if (props.hideYear || props.hideSecond) return corn
const vs = corn.split(' ')
return vs.slice(0, vs.length - 1).join(' ')
}
// 计算触发时间
const calculateNextExecutionTimes = (corn: string = cronExpression.value) => {
try {
const parse = expressionNoYear(corn)
// 解析表达式
const date = dateFormat(new Date())
const iter = CronParser.parseExpression(parse, {
currentDate: date
})
const result: string[] = []
for (let i = 1; i <= 5; i++) {
result.push(dateFormat(new Date(iter.next() as any)))
}
previewTimes.value = result.length > 0 ? result.join('\n') : '无执行时间'
// 回调
if (props.callback) {
props.callback(cronExpression.value, +new Date(), true)
}
} catch (e) {
previewTimes.value = '表达式错误'
// 回调
if (props.callback) {
props.callback(cronExpression.value, +new Date(), false)
}
}
}
const calcTriggerTimeList = useDebounceFn(calculateNextExecutionTimes, 500)
// 监听 cron 修改
watch(() => props.modelValue, (newVal) => {
if (newVal === cronExpression.value) {
return
}
parseCron()
})
// 监听 cron 修改
watch(cronExpression, (newValue) => {
calcTriggerTimeList()
emitValue(newValue)
assignInput()
})
// 根据 cron 解析
const parseCron = () => {
// 计算执行时间
calcTriggerTimeList()
if (!props.modelValue) {
return
}
const values = props.modelValue.split(' ').filter((item) => !!item)
if (!values || values.length <= 0) {
return
}
let i = 0
if (!props.hideSecond) second.value = values[i++]
if (values.length > i) minute.value = values[i++]
if (values.length > i) hour.value = values[i++]
if (values.length > i) day.value = values[i++]
if (values.length > i) month.value = values[i++]
if (values.length > i) week.value = values[i++]
if (values.length > i) year.value = values[i]
// 重新分配
assignInput()
}
// 重新分配
const assignInput = () => {
cronInputs.second = second.value
cronInputs.minute = minute.value
cronInputs.hour = hour.value
cronInputs.day = day.value
cronInputs.month = month.value
cronInputs.week = week.value
cronInputs.year = year.value
cronInputs.cron = cronExpression.value
}
// 修改 cron 解析内容
const onInputChange = () => {
second.value = cronInputs.second
minute.value = cronInputs.minute
hour.value = cronInputs.hour
day.value = cronInputs.day
month.value = cronInputs.month
week.value = cronInputs.week
year.value = cronInputs.year
}
// 修改 cron 输入框
const onInputCronChange = (value: string) => {
emitValue(value)
}
// 修改 cron
const emitValue = (value: string) => {
emit('change', value)
emit('update:modelValue', value)
}
onMounted(() => {
assignInput()
parseCron()
// 如果 modelValue 没有值则更新为 cronExpression
if (!props.modelValue) {
emitValue(cronExpression.value)
}
})
const checkCron = () => {
return (day.value === '?' && week.value === '?')
}
defineExpose({ checkCron })
</script>
<style lang="less" scoped>
.cron-inner {
user-select: none;
:deep(.arco-tabs-content) {
padding-top: 6px;
}
:deep(.cron-inner-config-list) {
text-align: left;
margin: 0 12px 4px 12px;
.item {
margin-top: 6px;
font-size: 14px;
width: 100%;
}
.choice {
padding: 4px 8px 4px 0;
}
.w60 {
margin: 0 8px !important;
padding: 0 8px !important;
width: 60px !important;
}
.w80 {
margin: 0 8px !important;
padding: 0 8px !important;
width: 80px !important;
}
.list {
margin: 0 20px;
}
.list-check-item {
padding: 1px 3px;
width: 4em;
}
.list-cn .list-check-item {
width: 5em;
}
.tip-info {
color: var(--color-text-3);
}
}
}
:deep(.arco-input-prepend) {
padding: 0 !important;
}
:deep(.arco-input-append) {
padding: 0 !important;
}
.preview-times {
color: var(--color-text-3);
margin: 2px 0 4px 0;
}
.allow-click {
width: 100%;
height: 100%;
padding: 0 12px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
user-select: none;
}
</style>

View File

@@ -0,0 +1,9 @@
// cron 参数类型
export interface CronPropType {
modelValue: string
disabled: boolean
hideSecond: boolean
hideYear: boolean
placeholder: string
callback: (expression: string, timestamp: number, validated: boolean) => void
}

View File

@@ -0,0 +1,67 @@
<template>
<a-modal v-model:visible="visible"
modal-class="modal-form-small"
title-align="start"
title="CRON 生成器"
:top="32"
:width="780"
:align-center="false"
:draggable="true"
:mask-closable="false"
:unmount-on-close="true"
:body-style="{ padding: '4px 16px 8px 16px' }">
<!-- cron 输入框 -->
<CronGeneratorInput ref="cronInputRef" v-model="cronExpression" />
<!-- 页脚 -->
<template #footer>
<a-button size="small" @click="handlerClose">关闭</a-button>
<a-button size="small"
type="primary"
@click="handlerOk">
确定
</a-button>
</template>
</a-modal>
</template>
<script lang="ts">
</script>
<script lang="ts" setup>
import { Message } from '@arco-design/web-vue'
import CronGeneratorInput from '@/components/GenCron/CronForm/index.vue'
const emits = defineEmits(['ok'])
const visible = ref<boolean>(false)
const cronInputRef = ref<InstanceType<typeof CronGeneratorInput>>()
const cronExpression = ref('')
// 打开新增
const open = (cron: string = '') => {
cronExpression.value = cron
visible.value = true
}
defineExpose({ open })
// 确定
const handlerOk = () => {
if (cronInputRef.value?.checkCron()) {
Message.error('日和周只能有一个为 [不设置]')
return
}
visible.value = false
emits('ok', cronExpression.value)
}
// 关闭
const handlerClose = () => {
visible.value = false
}
</script>
<style lang="less" scoped>
</style>

View File

@@ -11,18 +11,15 @@
<script lang="ts" setup>
import type { LabelValueState } from '@/types/global'
import type { GiCellTagType } from '@/components/GiCell/type'
defineOptions({ name: 'GiCellTag' })
const props = defineProps({
dict: {
type: Array<LabelValueState>,
required: true
},
value: {
type: [Number, String],
required: true
}
const props = withDefaults(defineProps<Partial<GiCellTagType>>(), {
dict: [{
label: '',
value: ''
}],
value: ''
})
const dictItem = computed((): LabelValueState => {

View File

@@ -0,0 +1,6 @@
import type { LabelValueState } from '@/types/global'
export interface GiCellTagType {
dict: LabelValueState[] | any[]
value: number | string
}

View File

@@ -1,6 +1,7 @@
import { browse, mapTree } from 'xe-utils'
import { camelCase, upperFirst } from 'lodash-es'
import { Message } from '@arco-design/web-vue'
import CronParser from 'cron-parser'
import { isExternal } from '@/utils/validate'
export function getProperty<T, K extends keyof T>(obj: T, key: K): T[K] {
@@ -279,3 +280,75 @@ export const fileToBase64 = (file: File): Promise<string> => {
reader.readAsDataURL(file)
})
}
export const YMD_HMS = 'yyyy-MM-dd HH:mm:ss'
/**
* 格式化时间
*/
export function dateFormat(date = new Date(), pattern = YMD_HMS) {
if (!date) {
return ''
}
const o = {
'M+': date.getMonth() + 1,
'd+': date.getDate(),
'H+': date.getHours(),
'm+': date.getMinutes(),
's+': date.getSeconds(),
'q+': Math.floor((date.getMonth() + 3) / 3),
'S+': date.getMilliseconds()
}
let formattedDate = pattern // Start with the pattern
// Year Handling
const yearMatch = formattedDate.match(/(y+)/)
if (yearMatch) {
formattedDate = formattedDate.replace(yearMatch[0], (`${date.getFullYear()}`).substring(4 - yearMatch[0].length))
}
// Other Formatters
for (const k in o) {
const reg = new RegExp(`(${k})`)
const match = formattedDate.match(reg)
if (match) {
formattedDate = formattedDate.replace(match[0], (match[0].length === 1) ? o[k] : (`00${o[k]}`).substring((`${o[k]}`).length))
}
}
return formattedDate
}
/**
* 不含年的 cron 表达式
* @param cron
*/
const expressionNoYear = (cron: string) => {
const vs = cron.split(' ')
return vs.slice(0, vs.length - 1).join(' ')
}
/**
* 解析cron表达式预计未来运行时间
* @param cron cron表达式
*/
export function parseCron(cron: string) {
try {
const parse = expressionNoYear(cron)
const iter = CronParser.parseExpression(parse, {
currentDate: dateFormat(new Date())
})
const result: string[] = []
for (let i = 1; i <= 5; i++) {
const nextDate = iter.next()
if (nextDate) {
result.push(dateFormat(new Date(nextDate as any)))
}
}
return result.length > 0 ? result.join('\n') : '无执行时间'
} catch (e) {
return '表达式错误'
}
}

View File

@@ -58,11 +58,17 @@
>
<template #suffix>秒</template>
</a-input-number>
<a-input
v-else
v-model="form.triggerInterval"
placeholder="请输入CRON表达式"
/>
<div v-else style="display: flex;">
<a-input
v-model="form.triggerInterval"
placeholder="请输入CRON表达式"
/>
<a-button @click="openGeneratorCron(form.triggerInterval)">
<template #icon>
<icon-history />
</template>
</a-button>
</div>
</a-form-item>
</a-col>
</a-row>
@@ -150,6 +156,7 @@
</a-row>
</fieldset>
</a-form>
<CronGeneratorModal ref="genModal" @ok="(e) => form.triggerInterval = e" />
</a-modal>
</template>
@@ -159,6 +166,7 @@ import { useWindowSize } from '@vueuse/core'
import { addJob, listGroup, updateJob } from '@/apis/schedule'
import { useForm } from '@/hooks'
import { useDict } from '@/hooks/app'
import CronGeneratorModal from '@/components/GenCron/CronModel/index.vue'
const emit = defineEmits<{
(e: 'save-success'): void
@@ -176,7 +184,7 @@ const dataId = ref()
const isUpdate = computed(() => !!dataId.value)
const title = computed(() => (isUpdate.value ? '修改任务' : '新增任务'))
const formRef = ref<FormInstance>()
const genModal = ref()
const rules: FormInstance['rules'] = {
groupName: [{ required: true, message: '请选择任务组' }],
jobName: [{ required: true, message: '请输入任务名称' }],
@@ -302,6 +310,11 @@ const onDeleteArgs = (index) => {
args.value.splice(index, 1)
}
// 打开生成表达式
const openGeneratorCron = (cron: string) => {
genModal.value.open(cron)
}
defineExpose({ onAdd, onUpdate })
</script>

View File

@@ -36,7 +36,14 @@
<template #triggerType="{ record }">
<GiCellTag :value="record.triggerType" :dict="job_trigger_type_enum" />:&nbsp;
<span v-if="record.triggerType === 2">{{ record.triggerInterval }} </span>
<span v-else>{{ record.triggerInterval }}</span>
<span v-else>
<a-popover title="最近5次运行时间" position="bottom">
<template #content>
<a-textarea :model-value="parseCron(record.triggerInterval)" :auto-size="true" style="margin-top: 10px" />
</template>
<a-link>{{ record.triggerInterval }}</a-link>
</a-popover>
</span>
</template>
<template #taskType="{ record }">
<GiCellTag :value="record.taskType" :dict="job_task_type_enum" />
@@ -77,7 +84,7 @@ import { type JobQuery, type JobResp, deleteJob, listGroup, listJob, triggerJob,
import type { TableInstanceColumns } from '@/components/GiTable/type'
import { useTable } from '@/hooks'
import { useDict } from '@/hooks/app'
import { isMobile } from '@/utils'
import { isMobile, parseCron } from '@/utils'
import has from '@/utils/has'
defineOptions({ name: 'ScheduleJob' })
@@ -180,13 +187,11 @@ const JobDetailDrawerRef = ref<InstanceType<typeof JobDetailDrawer>>()
const onDetail = (record: JobResp) => {
JobDetailDrawerRef.value?.onDetail(record)
}
const router = useRouter()
// 日志
const onLog = (record: JobResp) => {
router.push({ path: '/schedule/log', query: { jobId: record.id, jobName: record.jobName, groupName: record.groupName } })
}
onMounted(() => {
getGroupList()
})