|
@@ -0,0 +1,707 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <el-scrollbar class="w-full box-border p-12px">
|
|
|
|
|
+ <div class="schedule-setter">
|
|
|
|
|
+ <div class="w-full flex items-center justify-between beautify">
|
|
|
|
|
+ <label class="text-14px font-bold text-gray-700">{{ TEXT.title }}</label>
|
|
|
|
|
+ <el-button link @click="toggleMode">
|
|
|
|
|
+ <Icon :icon="formData.mode === 'visual' ? 'lucide:asterisk' : 'lucide:calendar-range'" />
|
|
|
|
|
+ <span>{{ formData.mode === 'visual' ? TEXT.modeCron : TEXT.modeVisual }}</span>
|
|
|
|
|
+ </el-button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <template v-if="formData.mode === 'visual'">
|
|
|
|
|
+ <div class="config-grid">
|
|
|
|
|
+ <div class="field-block">
|
|
|
|
|
+ <div class="field-label">{{ TEXT.frequency }}</div>
|
|
|
|
|
+ <el-select v-model="formData.frequency" class="w-full">
|
|
|
|
|
+ <el-option
|
|
|
|
|
+ v-for="item in FREQUENCY_OPTIONS"
|
|
|
|
|
+ :key="item.value"
|
|
|
|
|
+ :label="item.label"
|
|
|
|
|
+ :value="item.value"
|
|
|
|
|
+ />
|
|
|
|
|
+ </el-select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="field-block field-block--wide">
|
|
|
|
|
+ <div class="field-label">
|
|
|
|
|
+ {{ formData.frequency === 'hourly' ? TEXT.minute : TEXT.time }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="formData.frequency === 'hourly'" class="slider-panel">
|
|
|
|
|
+ <el-slider v-model="formData.visual_config.minute" :min="0" :max="59" :step="1" />
|
|
|
|
|
+ <span>{{ formData.visual_config.minute }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <el-time-picker
|
|
|
|
|
+ v-else
|
|
|
|
|
+ v-model="visualTimeModel"
|
|
|
|
|
+ format="HH:mm A"
|
|
|
|
|
+ value-format="HH:mm"
|
|
|
|
|
+ style="width: 100%"
|
|
|
|
|
+ :clearable="false"
|
|
|
|
|
+ :suffix="timezoneLabel"
|
|
|
|
|
+ >
|
|
|
|
|
+ </el-time-picker>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="formData.frequency === 'weekly'" class="field-block">
|
|
|
|
|
+ <div class="field-label">{{ TEXT.weekday }}</div>
|
|
|
|
|
+ <div class="tag-grid tag-grid--week">
|
|
|
|
|
+ <el-check-tag
|
|
|
|
|
+ v-for="item in WEEKDAY_OPTIONS"
|
|
|
|
|
+ :key="item.value"
|
|
|
|
|
+ :checked="selectedWeekdays.has(item.value)"
|
|
|
|
|
+ class="schedule-tag"
|
|
|
|
|
+ @change="onWeekdayTagChange(item.value, $event)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ item.label }}
|
|
|
|
|
+ </el-check-tag>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div v-if="formData.frequency === 'monthly'" class="field-block">
|
|
|
|
|
+ <div class="field-label">{{ TEXT.monthDay }}</div>
|
|
|
|
|
+ <div class="tag-grid tag-grid--month">
|
|
|
|
|
+ <el-check-tag
|
|
|
|
|
+ v-for="day in MONTH_DAYS"
|
|
|
|
|
+ :key="day"
|
|
|
|
|
+ :checked="selectedMonthlyDays.has(day)"
|
|
|
|
|
+ class="schedule-tag"
|
|
|
|
|
+ @change="onMonthDayTagChange(day, $event)"
|
|
|
|
|
+ >
|
|
|
|
|
+ {{ day }}
|
|
|
|
|
+ </el-check-tag>
|
|
|
|
|
+ <el-check-tag
|
|
|
|
|
+ :checked="selectedMonthlyDays.has('last')"
|
|
|
|
|
+ class="schedule-tag schedule-tag--last"
|
|
|
|
|
+ @change="onMonthDayTagChange('last', $event)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <span>{{ TEXT.lastDay }}</span>
|
|
|
|
|
+ <el-tooltip :content="TEXT.lastDayTip" placement="top">
|
|
|
|
|
+ <el-icon class="hint-icon"><QuestionFilled /></el-icon>
|
|
|
|
|
+ </el-tooltip>
|
|
|
|
|
+ </el-check-tag>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <el-divider />
|
|
|
|
|
+
|
|
|
|
|
+ <div class="field-block">
|
|
|
|
|
+ <div class="field-label">{{ TEXT.nextRuns }}</div>
|
|
|
|
|
+ <div class="preview-panel">
|
|
|
|
|
+ <div v-if="nextRuns.length" class="preview-list">
|
|
|
|
|
+ <div v-for="(item, index) in nextRuns" :key="item.timestamp" class="preview-item">
|
|
|
|
|
+ <span class="preview-index">{{ padIndex(index + 1) }}</span>
|
|
|
|
|
+ <span class="preview-text">{{ item.label }}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div v-else class="preview-empty">{{ TEXT.previewEmpty }}</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+
|
|
|
|
|
+ <template v-else>
|
|
|
|
|
+ <div class="field-block">
|
|
|
|
|
+ <div class="field-label">{{ TEXT.cronLabel }}</div>
|
|
|
|
|
+ <el-input
|
|
|
|
|
+ v-model="formData.cron_expression"
|
|
|
|
|
+ class="cron-input"
|
|
|
|
|
+ :placeholder="TEXT.cronPlaceholder"
|
|
|
|
|
+ />
|
|
|
|
|
+ <div class="cron-hint" :class="{ 'cron-hint--error': !isCronExpressionValid }">
|
|
|
|
|
+ {{ cronHint }}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </template>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </el-scrollbar>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { QuestionFilled } from '@element-plus/icons-vue'
|
|
|
|
|
+import { computed, watch } from 'vue'
|
|
|
|
|
+import { Icon } from '@repo/ui'
|
|
|
|
|
+
|
|
|
|
|
+import { useSetterModel } from '../_shared/useSetterModel'
|
|
|
|
|
+
|
|
|
|
|
+import type { ScheduleData, ScheduleFrequency, ScheduleWeekday } from './index'
|
|
|
|
|
+
|
|
|
|
|
+interface Emits {
|
|
|
|
|
+ (e: 'update', value: ScheduleData): void
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+type ScheduleMonthDay = number | 'last'
|
|
|
|
|
+type PreviewItem = {
|
|
|
|
|
+ timestamp: string
|
|
|
|
|
+ label: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const TEXT = {
|
|
|
|
|
+ title: '定时触发',
|
|
|
|
|
+ modeCron: '使用 Cron 表达式',
|
|
|
|
|
+ modeVisual: '使用可视化配置',
|
|
|
|
|
+ frequency: '频率',
|
|
|
|
|
+ minute: '分钟',
|
|
|
|
|
+ time: '时间',
|
|
|
|
|
+ weekday: '星期',
|
|
|
|
|
+ monthDay: '天',
|
|
|
|
|
+ lastDay: '最后一天',
|
|
|
|
|
+ lastDayTip: '按每个月的自然月最后一天执行',
|
|
|
|
|
+ nextRuns: '接下来 5 次执行时间',
|
|
|
|
|
+ previewEmpty: '当前配置暂时无法推导执行时间',
|
|
|
|
|
+ cronLabel: 'Cron 表达式',
|
|
|
|
|
+ cronPlaceholder: '例如:0 0 0 * * ? *',
|
|
|
|
|
+ cronSupport: '支持 5 到 7 段 Cron 表达式',
|
|
|
|
|
+ cronUsing: '将按输入的 Cron 表达式触发',
|
|
|
|
|
+ cronInvalid: 'Cron 表达式格式不正确,请输入 5 到 7 段'
|
|
|
|
|
+} as const
|
|
|
|
|
+
|
|
|
|
|
+const FREQUENCY_OPTIONS: Array<{ label: string; value: ScheduleFrequency }> = [
|
|
|
|
|
+ { label: '每小时', value: 'hourly' },
|
|
|
|
|
+ { label: '每日', value: 'daily' },
|
|
|
|
|
+ { label: '每周', value: 'weekly' },
|
|
|
|
|
+ { label: '每月', value: 'monthly' }
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const WEEKDAY_OPTIONS: Array<{ label: string; value: ScheduleWeekday; day: number }> = [
|
|
|
|
|
+ { label: 'Sun', value: 'sun', day: 0 },
|
|
|
|
|
+ { label: 'Mon', value: 'mon', day: 1 },
|
|
|
|
|
+ { label: 'Tue', value: 'tue', day: 2 },
|
|
|
|
|
+ { label: 'Wed', value: 'wed', day: 3 },
|
|
|
|
|
+ { label: 'Thu', value: 'thu', day: 4 },
|
|
|
|
|
+ { label: 'Fri', value: 'fri', day: 5 },
|
|
|
|
|
+ { label: 'Sat', value: 'sat', day: 6 }
|
|
|
|
|
+]
|
|
|
|
|
+
|
|
|
|
|
+const WEEKDAY_TO_CRON: Record<ScheduleWeekday, string> = {
|
|
|
|
|
+ sun: 'SUN',
|
|
|
|
|
+ mon: 'MON',
|
|
|
|
|
+ tue: 'TUE',
|
|
|
|
|
+ wed: 'WED',
|
|
|
|
|
+ thu: 'THU',
|
|
|
|
|
+ fri: 'FRI',
|
|
|
|
|
+ sat: 'SAT'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const MONTH_DAYS = Array.from({ length: 31 }, (_, index) => index + 1)
|
|
|
|
|
+const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'
|
|
|
|
|
+
|
|
|
|
|
+const props = defineProps<{
|
|
|
|
|
+ data: ScheduleData
|
|
|
|
|
+}>()
|
|
|
|
|
+
|
|
|
|
|
+const emit = defineEmits<Emits>()
|
|
|
|
|
+const formData = useSetterModel<ScheduleData>(props, emit)
|
|
|
|
|
+
|
|
|
|
|
+const normalizeTime = (value?: string) => {
|
|
|
|
|
+ return /^([01]\d|2[0-3]):([0-5]\d)$/.test(value || '') ? value! : '00:00'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const clampMinute = (value: unknown) => {
|
|
|
|
|
+ const minute = Number(value)
|
|
|
|
|
+ if (Number.isNaN(minute)) {
|
|
|
|
|
+ return 0
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Math.min(59, Math.max(0, Math.trunc(minute)))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const sortWeekdays = (weekdays: ScheduleWeekday[]) => {
|
|
|
|
|
+ const orderMap = new Map(WEEKDAY_OPTIONS.map((item) => [item.value, item.day]))
|
|
|
|
|
+ return [...new Set(weekdays)].sort((left, right) => {
|
|
|
|
|
+ return (orderMap.get(left) || 0) - (orderMap.get(right) || 0)
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const normalizeMonthlyDays = (values: Array<ScheduleMonthDay | string>): ScheduleMonthDay[] => {
|
|
|
|
|
+ const parsedValues = values
|
|
|
|
|
+ .map((item) => {
|
|
|
|
|
+ if (item === 'last') {
|
|
|
|
|
+ return 'last' as const
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const day = Number(item)
|
|
|
|
|
+ if (Number.isNaN(day)) {
|
|
|
|
|
+ return null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return Math.min(31, Math.max(1, Math.trunc(day)))
|
|
|
|
|
+ })
|
|
|
|
|
+ .filter((item): item is ScheduleMonthDay => item !== null)
|
|
|
|
|
+
|
|
|
|
|
+ if (!parsedValues.length) {
|
|
|
|
|
+ return [1]
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const uniqueDays = Array.from(new Set(parsedValues))
|
|
|
|
|
+
|
|
|
|
|
+ return uniqueDays.sort((left, right) => {
|
|
|
|
|
+ if (left === 'last') return 1
|
|
|
|
|
+ if (right === 'last') return -1
|
|
|
|
|
+ return left - right
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const ensureScheduleState = () => {
|
|
|
|
|
+ if (!formData.value.title) {
|
|
|
|
|
+ formData.value.title = TEXT.title
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.type) {
|
|
|
|
|
+ formData.value.type = 'trigger-schedule'
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.variables) {
|
|
|
|
|
+ formData.value.variables = []
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.outputs) {
|
|
|
|
|
+ formData.value.outputs = []
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.default_value) {
|
|
|
|
|
+ formData.value.default_value = []
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.retry_config) {
|
|
|
|
|
+ formData.value.retry_config = {
|
|
|
|
|
+ max_retries: 3,
|
|
|
|
|
+ retry_enabled: false,
|
|
|
|
|
+ retry_interval: 100
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.error_strategy) {
|
|
|
|
|
+ formData.value.error_strategy = 'none'
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!['visual', 'cron'].includes(formData.value.mode)) {
|
|
|
|
|
+ formData.value.mode = 'visual'
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!['hourly', 'daily', 'weekly', 'monthly'].includes(formData.value.frequency)) {
|
|
|
|
|
+ formData.value.frequency = 'daily'
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.timezone) {
|
|
|
|
|
+ formData.value.timezone = browserTimeZone
|
|
|
|
|
+ }
|
|
|
|
|
+ if (!formData.value.visual_config || typeof formData.value.visual_config !== 'object') {
|
|
|
|
|
+ formData.value.visual_config = {
|
|
|
|
|
+ minute: 0,
|
|
|
|
|
+ time: '00:00',
|
|
|
|
|
+ weekdays: ['sun'],
|
|
|
|
|
+ monthly_days: [1]
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ formData.value.visual_config.minute = clampMinute(formData.value.visual_config.minute)
|
|
|
|
|
+ formData.value.visual_config.time = normalizeTime(formData.value.visual_config.time)
|
|
|
|
|
+ formData.value.visual_config.weekdays = sortWeekdays(
|
|
|
|
|
+ (formData.value.visual_config.weekdays || ['sun']) as ScheduleWeekday[]
|
|
|
|
|
+ )
|
|
|
|
|
+ formData.value.visual_config.monthly_days = normalizeMonthlyDays(
|
|
|
|
|
+ formData.value.visual_config.monthly_days || [1]
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+ensureScheduleState()
|
|
|
|
|
+
|
|
|
|
|
+const parseTime = (value: string) => {
|
|
|
|
|
+ const [hourText = '0', minuteText = '0'] = normalizeTime(value).split(':')
|
|
|
|
|
+ return {
|
|
|
|
|
+ hour: Math.min(23, Math.max(0, Number(hourText))),
|
|
|
|
|
+ minute: Math.min(59, Math.max(0, Number(minuteText)))
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const buildQuartzCronExpression = (data: ScheduleData) => {
|
|
|
|
|
+ const minute = clampMinute(data.visual_config.minute)
|
|
|
|
|
+ const { hour, minute: timeMinute } = parseTime(data.visual_config.time)
|
|
|
|
|
+
|
|
|
|
|
+ switch (data.frequency) {
|
|
|
|
|
+ case 'hourly':
|
|
|
|
|
+ return `0 ${minute} * * * ? *`
|
|
|
|
|
+ case 'weekly':
|
|
|
|
|
+ return `0 ${timeMinute} ${hour} ? * ${sortWeekdays(data.visual_config.weekdays)
|
|
|
|
|
+ .map((item) => WEEKDAY_TO_CRON[item])
|
|
|
|
|
+ .join(',')} *`
|
|
|
|
|
+ case 'monthly':
|
|
|
|
|
+ return `0 ${timeMinute} ${hour} ${normalizeMonthlyDays(data.visual_config.monthly_days)
|
|
|
|
|
+ .map((item) => (item === 'last' ? 'L' : item))
|
|
|
|
|
+ .join(',')} * ? *`
|
|
|
|
|
+ case 'daily':
|
|
|
|
|
+ default:
|
|
|
|
|
+ return `0 ${timeMinute} ${hour} * * ? *`
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const selectedWeekdays = computed<Set<ScheduleWeekday>>(() => {
|
|
|
|
|
+ return new Set(sortWeekdays(formData.value.visual_config.weekdays))
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const selectedMonthlyDays = computed<Set<ScheduleMonthDay>>(() => {
|
|
|
|
|
+ return new Set(normalizeMonthlyDays(formData.value.visual_config.monthly_days))
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const visualTimeModel = computed<string>({
|
|
|
|
|
+ get: () => normalizeTime(formData.value.visual_config.time),
|
|
|
|
|
+ set: (value: string) => {
|
|
|
|
|
+ formData.value.visual_config.time = normalizeTime(value)
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const timezoneLabel = computed(() => {
|
|
|
|
|
+ const offset = -new Date().getTimezoneOffset()
|
|
|
|
|
+ const sign = offset >= 0 ? '+' : '-'
|
|
|
|
|
+ const absolute = Math.abs(offset)
|
|
|
|
|
+ const hours = Math.floor(absolute / 60)
|
|
|
|
|
+ const minutes = absolute % 60
|
|
|
|
|
+
|
|
|
|
|
+ if (minutes === 0) {
|
|
|
|
|
+ return `UTC${sign}${hours}`
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return `UTC${sign}${hours}:${String(minutes).padStart(2, '0')}`
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const toggleMode = () => {
|
|
|
|
|
+ formData.value.cron_expression = buildQuartzCronExpression(formData.value)
|
|
|
|
|
+ formData.value.mode = formData.value.mode === 'visual' ? 'cron' : 'visual'
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleWeekdayChange = (weekday: ScheduleWeekday, checked: boolean) => {
|
|
|
|
|
+ const weekdays = sortWeekdays(formData.value.visual_config.weekdays)
|
|
|
|
|
+ const nextWeekdays = checked
|
|
|
|
|
+ ? [...weekdays, weekday]
|
|
|
|
|
+ : weekdays.filter((item) => item !== weekday)
|
|
|
|
|
+
|
|
|
|
|
+ formData.value.visual_config.weekdays = sortWeekdays(
|
|
|
|
|
+ nextWeekdays.length ? nextWeekdays : weekdays
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const handleMonthDayChange = (day: ScheduleMonthDay, checked: boolean) => {
|
|
|
|
|
+ const monthlyDays = normalizeMonthlyDays(formData.value.visual_config.monthly_days)
|
|
|
|
|
+ const nextMonthlyDays = checked
|
|
|
|
|
+ ? [...monthlyDays, day]
|
|
|
|
|
+ : monthlyDays.filter((item) => item !== day)
|
|
|
|
|
+
|
|
|
|
|
+ formData.value.visual_config.monthly_days = normalizeMonthlyDays(
|
|
|
|
|
+ nextMonthlyDays.length ? nextMonthlyDays : monthlyDays
|
|
|
|
|
+ )
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onWeekdayTagChange = (weekday: ScheduleWeekday, checked: unknown) => {
|
|
|
|
|
+ handleWeekdayChange(weekday, Boolean(checked))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const onMonthDayTagChange = (day: ScheduleMonthDay, checked: unknown) => {
|
|
|
|
|
+ handleMonthDayChange(day, Boolean(checked))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const createCandidate = (base: Date, hour: number, minute: number) => {
|
|
|
|
|
+ const candidate = new Date(base)
|
|
|
|
|
+ candidate.setHours(hour, minute, 0, 0)
|
|
|
|
|
+ return candidate
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const getLastDayOfMonth = (date: Date) => {
|
|
|
|
|
+ return new Date(date.getFullYear(), date.getMonth() + 1, 0).getDate()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const generateHourlyRuns = (count: number) => {
|
|
|
|
|
+ const nextRuns: Date[] = []
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const scheduleMinute = clampMinute(formData.value.visual_config.minute)
|
|
|
|
|
+ let cursor = new Date(now)
|
|
|
|
|
+ cursor.setSeconds(0, 0)
|
|
|
|
|
+ cursor.setMinutes(scheduleMinute, 0, 0)
|
|
|
|
|
+
|
|
|
|
|
+ if (cursor <= now) {
|
|
|
|
|
+ cursor.setHours(cursor.getHours() + 1)
|
|
|
|
|
+ cursor.setMinutes(scheduleMinute, 0, 0)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ while (nextRuns.length < count) {
|
|
|
|
|
+ nextRuns.push(new Date(cursor))
|
|
|
|
|
+ cursor.setHours(cursor.getHours() + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nextRuns
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const generateDailyRuns = (count: number) => {
|
|
|
|
|
+ const nextRuns: Date[] = []
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const { hour, minute } = parseTime(formData.value.visual_config.time)
|
|
|
|
|
+ let cursor = createCandidate(now, hour, minute)
|
|
|
|
|
+
|
|
|
|
|
+ if (cursor <= now) {
|
|
|
|
|
+ cursor.setDate(cursor.getDate() + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ while (nextRuns.length < count) {
|
|
|
|
|
+ nextRuns.push(new Date(cursor))
|
|
|
|
|
+ cursor.setDate(cursor.getDate() + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nextRuns
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const generateWeeklyRuns = (count: number) => {
|
|
|
|
|
+ const nextRuns: Date[] = []
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const { hour, minute } = parseTime(formData.value.visual_config.time)
|
|
|
|
|
+ const weekdays = new Set(
|
|
|
|
|
+ WEEKDAY_OPTIONS.filter((item) =>
|
|
|
|
|
+ formData.value.visual_config.weekdays.includes(item.value)
|
|
|
|
|
+ ).map((item) => item.day)
|
|
|
|
|
+ )
|
|
|
|
|
+ let cursor = new Date(now)
|
|
|
|
|
+ cursor.setHours(0, 0, 0, 0)
|
|
|
|
|
+
|
|
|
|
|
+ for (let dayOffset = 0; dayOffset < 370 && nextRuns.length < count; dayOffset += 1) {
|
|
|
|
|
+ const candidate = createCandidate(cursor, hour, minute)
|
|
|
|
|
+ if (weekdays.has(candidate.getDay()) && candidate > now) {
|
|
|
|
|
+ nextRuns.push(candidate)
|
|
|
|
|
+ }
|
|
|
|
|
+ cursor.setDate(cursor.getDate() + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nextRuns
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const generateMonthlyRuns = (count: number) => {
|
|
|
|
|
+ const nextRuns: Date[] = []
|
|
|
|
|
+ const now = new Date()
|
|
|
|
|
+ const { hour, minute } = parseTime(formData.value.visual_config.time)
|
|
|
|
|
+ const monthlyDays = normalizeMonthlyDays(formData.value.visual_config.monthly_days)
|
|
|
|
|
+ let cursor = new Date(now)
|
|
|
|
|
+ cursor.setHours(0, 0, 0, 0)
|
|
|
|
|
+
|
|
|
|
|
+ for (let dayOffset = 0; dayOffset < 740 && nextRuns.length < count; dayOffset += 1) {
|
|
|
|
|
+ const candidate = createCandidate(cursor, hour, minute)
|
|
|
|
|
+ const currentDate = candidate.getDate()
|
|
|
|
|
+ const isLastDay = currentDate === getLastDayOfMonth(candidate)
|
|
|
|
|
+ const matched = monthlyDays.some((item) => (item === 'last' ? isLastDay : item === currentDate))
|
|
|
|
|
+
|
|
|
|
|
+ if (matched && candidate > now) {
|
|
|
|
|
+ nextRuns.push(candidate)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ cursor.setDate(cursor.getDate() + 1)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return nextRuns
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const formatPreviewDate = (date: Date) => {
|
|
|
|
|
+ const formatter = new Intl.DateTimeFormat('en-US', {
|
|
|
|
|
+ month: 'long',
|
|
|
|
|
+ day: 'numeric',
|
|
|
|
|
+ year: 'numeric',
|
|
|
|
|
+ hour: 'numeric',
|
|
|
|
|
+ minute: '2-digit',
|
|
|
|
|
+ hour12: true
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ return `${formatter.format(date)} (${timezoneLabel.value})`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const nextRuns = computed<PreviewItem[]>(() => {
|
|
|
|
|
+ let dates: Date[] = []
|
|
|
|
|
+ switch (formData.value.frequency) {
|
|
|
|
|
+ case 'hourly':
|
|
|
|
|
+ dates = generateHourlyRuns(5)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'weekly':
|
|
|
|
|
+ dates = generateWeeklyRuns(5)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'monthly':
|
|
|
|
|
+ dates = generateMonthlyRuns(5)
|
|
|
|
|
+ break
|
|
|
|
|
+ case 'daily':
|
|
|
|
|
+ default:
|
|
|
|
|
+ dates = generateDailyRuns(5)
|
|
|
|
|
+ break
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return dates.map((date) => ({
|
|
|
|
|
+ timestamp: `${date.getTime()}`,
|
|
|
|
|
+ label: formatPreviewDate(date)
|
|
|
|
|
+ }))
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const isCronExpressionValid = computed(() => {
|
|
|
|
|
+ const value = formData.value.cron_expression?.trim()
|
|
|
|
|
+ if (!value) {
|
|
|
|
|
+ return false
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return /^(?:\S+\s+){4,6}\S+$/.test(value)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const cronHint = computed(() => {
|
|
|
|
|
+ if (!formData.value.cron_expression?.trim()) {
|
|
|
|
|
+ return TEXT.cronSupport
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return isCronExpressionValid.value ? TEXT.cronUsing : TEXT.cronInvalid
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const padIndex = (value: number) => String(value).padStart(2, '0')
|
|
|
|
|
+
|
|
|
|
|
+watch(
|
|
|
|
|
+ () => [
|
|
|
|
|
+ formData.value.mode,
|
|
|
|
|
+ formData.value.frequency,
|
|
|
|
|
+ formData.value.visual_config?.minute,
|
|
|
|
|
+ formData.value.visual_config?.time,
|
|
|
|
|
+ (formData.value.visual_config?.weekdays || []).join(','),
|
|
|
|
|
+ (formData.value.visual_config?.monthly_days || []).join(',')
|
|
|
|
|
+ ],
|
|
|
|
|
+ () => {
|
|
|
|
|
+ if (formData.value.mode === 'visual' || !formData.value.cron_expression?.trim()) {
|
|
|
|
|
+ formData.value.cron_expression = buildQuartzCronExpression(formData.value)
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ { immediate: true }
|
|
|
|
|
+)
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style scoped lang="less">
|
|
|
|
|
+.schedule-setter {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 18px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.schedule-header {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: space-between;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.schedule-title {
|
|
|
|
|
+ font-size: 16px;
|
|
|
|
|
+ font-weight: 700;
|
|
|
|
|
+ color: #344054;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.mode-toggle {
|
|
|
|
|
+ display: inline-flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ border: 0;
|
|
|
|
|
+ background: transparent;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #344054;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.config-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: minmax(0, 1fr) minmax(0, 2fr);
|
|
|
|
|
+ gap: 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.field-block {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.field-block--wide {
|
|
|
|
|
+ min-width: 0;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.field-label {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #667085;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.slider-panel,
|
|
|
|
|
+.time-panel,
|
|
|
|
|
+.preview-panel {
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ background: #f2f5f9;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.slider-panel {
|
|
|
|
|
+ padding: 0px 14px 0px 8px;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.time-panel {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tag-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tag-grid--week {
|
|
|
|
|
+ grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.tag-grid--month {
|
|
|
|
|
+ grid-template-columns: repeat(7, minmax(0, 1fr));
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.schedule-tag {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ // min-height: 38px;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ color: #475467;
|
|
|
|
|
+ background: #f8fafc;
|
|
|
|
|
+ border: 1px solid #e5e7eb;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.schedule-tag--last {
|
|
|
|
|
+ grid-column: span 3;
|
|
|
|
|
+ gap: 6px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.hint-icon {
|
|
|
|
|
+ color: #98a2b3;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-panel {
|
|
|
|
|
+ padding: 14px 16px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-list {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ gap: 8px;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-item {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: baseline;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, 'Liberation Mono', monospace;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #475467;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-index {
|
|
|
|
|
+ color: #98a2b3;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-text {
|
|
|
|
|
+ word-break: break-word;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.preview-empty,
|
|
|
|
|
+.cron-hint {
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #667085;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.cron-hint--error {
|
|
|
|
|
+ color: #f04438;
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|