mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat(web): structured schedule create/edit UI
Replace the read-only schedule list with a form-driven builder so users never hand-edit cron patterns. A canonical ScheduleFormState feeds two inverse pure functions (toCron / fromCron) that guarantee round-trip equivalence, so new and edit flows share the exact same UI state shape even though the DB stores only the pattern. Unrecognised patterns (AI- generated ranges/steps, descriptors, 6-field seconds cron) fall back losslessly to an advanced mode instead of being silently rewritten. The dialog adds live previews (human-readable via cronstrue, next 3 trigger times via cron-parser evaluated in the bot timezone) and row actions for edit / enable-toggle / delete.
This commit is contained in:
@@ -23,6 +23,8 @@
|
||||
"@xterm/addon-serialize": "^0.14.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
"cron-parser": "^5.5.0",
|
||||
"cronstrue": "^3.14.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"echarts": "^6.0.0",
|
||||
"katex": "^0.16.28",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"cancel": "Cancel",
|
||||
"back": "Back",
|
||||
"save": "Save",
|
||||
"create": "Create",
|
||||
"add": "Add",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
@@ -972,6 +973,8 @@
|
||||
"compactionModel": "Compaction Model",
|
||||
"compactionModelDescription": "Select a model for summarization. Defaults to the bot's chat model if not set.",
|
||||
"compactionModelPlaceholder": "Use chat model (default)",
|
||||
"showToolCallsInIM": "Show Tool Calls in IM",
|
||||
"showToolCallsInIMDescription": "Surface tool execution status (running / completed / failed) as a single message in IM channels. Off by default.",
|
||||
"browserContext": "Browser Context",
|
||||
"browserContextPlaceholder": "Select browser context (disabled if empty)",
|
||||
"allowGuest": "Default ACL Effect",
|
||||
@@ -1240,7 +1243,86 @@
|
||||
"updatedAt": "Updated",
|
||||
"statusEnabled": "Enabled",
|
||||
"statusDisabled": "Disabled",
|
||||
"unlimited": "∞"
|
||||
"unlimited": "∞",
|
||||
"actions": "Actions",
|
||||
"create": "New Schedule",
|
||||
"edit": "Edit",
|
||||
"delete": "Delete",
|
||||
"deleteConfirm": "Delete schedule \"{name}\"? This cannot be undone.",
|
||||
"deleteSuccess": "Schedule deleted",
|
||||
"deleteFailed": "Failed to delete schedule",
|
||||
"saveSuccess": "Schedule saved",
|
||||
"saveFailed": "Failed to save schedule",
|
||||
"form": {
|
||||
"name": "Name",
|
||||
"namePlaceholder": "e.g. Morning brief",
|
||||
"description": "Description",
|
||||
"descriptionPlaceholder": "What this schedule does",
|
||||
"command": "Instruction",
|
||||
"commandPlaceholder": "Tell the bot what to do when this fires",
|
||||
"commandHint": "This will be delivered to the bot as a message each time the schedule triggers.",
|
||||
"pattern": "Schedule",
|
||||
"mode": "Mode",
|
||||
"everyMinutes": "Every N minutes (1-59)",
|
||||
"atMinute": "At minute of each hour (0-59)",
|
||||
"hour": "Hour",
|
||||
"hours": "Hours",
|
||||
"hoursHint": "Click to select one or more hours of the day.",
|
||||
"minute": "Minute",
|
||||
"weekdays": "Days of week",
|
||||
"monthDays": "Days of month",
|
||||
"month": "Month",
|
||||
"monthDay": "Day",
|
||||
"maxCalls": "Run limit",
|
||||
"maxCallsUnlimited": "Unlimited",
|
||||
"enabled": "Enabled",
|
||||
"patternPreview": "Cron pattern",
|
||||
"nextRuns": "Next runs ({tz})",
|
||||
"invalidPattern": "Pattern is invalid",
|
||||
"advancedPattern": "Cron expression",
|
||||
"advancedHint": "Standard cron: minute hour day-of-month month day-of-week. Descriptors like @daily are also accepted."
|
||||
},
|
||||
"mode": {
|
||||
"minutes": "Every N minutes",
|
||||
"hourly": "Hourly",
|
||||
"daily": "Daily",
|
||||
"weekly": "Weekly",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"advanced": "Advanced (cron)"
|
||||
},
|
||||
"modeHint": {
|
||||
"minutes": "Trigger every N minutes.",
|
||||
"hourly": "Trigger once every hour, at a given minute.",
|
||||
"daily": "Trigger every day at the selected hours.",
|
||||
"weekly": "Trigger on the selected days of the week.",
|
||||
"monthly": "Trigger on the selected days of the month.",
|
||||
"yearly": "Trigger once a year on a specific month and day.",
|
||||
"advanced": "Write a raw cron expression for full control."
|
||||
},
|
||||
"weekday": {
|
||||
"sun": "Sun",
|
||||
"mon": "Mon",
|
||||
"tue": "Tue",
|
||||
"wed": "Wed",
|
||||
"thu": "Thu",
|
||||
"fri": "Fri",
|
||||
"sat": "Sat"
|
||||
},
|
||||
"month": {
|
||||
"jan": "January",
|
||||
"feb": "February",
|
||||
"mar": "March",
|
||||
"apr": "April",
|
||||
"may": "May",
|
||||
"jun": "June",
|
||||
"jul": "July",
|
||||
"aug": "August",
|
||||
"sep": "September",
|
||||
"oct": "October",
|
||||
"nov": "November",
|
||||
"dec": "December"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "History",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"cancel": "取消",
|
||||
"back": "返回",
|
||||
"save": "保存",
|
||||
"create": "创建",
|
||||
"add": "添加",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
@@ -968,6 +969,8 @@
|
||||
"compactionModel": "压缩模型",
|
||||
"compactionModelDescription": "选择用于摘要的模型,未设置时默认使用聊天模型。",
|
||||
"compactionModelPlaceholder": "使用聊天模型(默认)",
|
||||
"showToolCallsInIM": "在 IM 中显示工具调用",
|
||||
"showToolCallsInIMDescription": "在 IM 频道中以单条消息持续展示工具执行的状态(进行中 / 已完成 / 失败)。默认关闭。",
|
||||
"browserContext": "浏览器上下文",
|
||||
"browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)",
|
||||
"allowGuest": "ACL 默认行为",
|
||||
@@ -1236,7 +1239,86 @@
|
||||
"updatedAt": "更新时间",
|
||||
"statusEnabled": "已启用",
|
||||
"statusDisabled": "已禁用",
|
||||
"unlimited": "无限制"
|
||||
"unlimited": "无限制",
|
||||
"actions": "操作",
|
||||
"create": "新建任务",
|
||||
"edit": "编辑",
|
||||
"delete": "删除",
|
||||
"deleteConfirm": "确定删除任务 \"{name}\" 吗?此操作无法撤销。",
|
||||
"deleteSuccess": "已删除",
|
||||
"deleteFailed": "删除失败",
|
||||
"saveSuccess": "已保存",
|
||||
"saveFailed": "保存失败",
|
||||
"form": {
|
||||
"name": "名称",
|
||||
"namePlaceholder": "例如:每日早报",
|
||||
"description": "描述",
|
||||
"descriptionPlaceholder": "这个任务做什么",
|
||||
"command": "指令",
|
||||
"commandPlaceholder": "每次触发时让 Bot 做什么",
|
||||
"commandHint": "任务触发时,这段内容会作为消息发送给 Bot。",
|
||||
"pattern": "调度规则",
|
||||
"mode": "模式",
|
||||
"everyMinutes": "每 N 分钟 (1-59)",
|
||||
"atMinute": "每小时的第 M 分钟 (0-59)",
|
||||
"hour": "小时",
|
||||
"hours": "小时",
|
||||
"hoursHint": "点击选择一个或多个小时。",
|
||||
"minute": "分钟",
|
||||
"weekdays": "星期",
|
||||
"monthDays": "每月日期",
|
||||
"month": "月份",
|
||||
"monthDay": "日期",
|
||||
"maxCalls": "运行次数限制",
|
||||
"maxCallsUnlimited": "不限制",
|
||||
"enabled": "启用",
|
||||
"patternPreview": "Cron 表达式",
|
||||
"nextRuns": "接下来的触发时间({tz})",
|
||||
"invalidPattern": "表达式无效",
|
||||
"advancedPattern": "Cron 表达式",
|
||||
"advancedHint": "标准 cron 格式:分 时 日 月 星期。也支持 @daily 等描述符。"
|
||||
},
|
||||
"mode": {
|
||||
"minutes": "每 N 分钟",
|
||||
"hourly": "每小时",
|
||||
"daily": "每天",
|
||||
"weekly": "每周",
|
||||
"monthly": "每月",
|
||||
"yearly": "每年",
|
||||
"advanced": "高级(Cron)"
|
||||
},
|
||||
"modeHint": {
|
||||
"minutes": "每 N 分钟触发一次。",
|
||||
"hourly": "每小时在指定分钟触发。",
|
||||
"daily": "每天在选定的小时触发。",
|
||||
"weekly": "在选定的星期几触发。",
|
||||
"monthly": "在每月选定的日期触发。",
|
||||
"yearly": "每年在指定月份和日期触发。",
|
||||
"advanced": "直接编写 cron 表达式以获得完全控制。"
|
||||
},
|
||||
"weekday": {
|
||||
"sun": "日",
|
||||
"mon": "一",
|
||||
"tue": "二",
|
||||
"wed": "三",
|
||||
"thu": "四",
|
||||
"fri": "五",
|
||||
"sat": "六"
|
||||
},
|
||||
"month": {
|
||||
"jan": "一月",
|
||||
"feb": "二月",
|
||||
"mar": "三月",
|
||||
"apr": "四月",
|
||||
"may": "五月",
|
||||
"jun": "六月",
|
||||
"jul": "七月",
|
||||
"aug": "八月",
|
||||
"sep": "九月",
|
||||
"oct": "十月",
|
||||
"nov": "十一月",
|
||||
"dec": "十二月"
|
||||
}
|
||||
},
|
||||
"history": {
|
||||
"title": "对话历史",
|
||||
|
||||
@@ -27,6 +27,13 @@
|
||||
/>
|
||||
{{ $t('common.refresh') }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
@click="handleNew"
|
||||
>
|
||||
<Plus class="mr-1 size-4" />
|
||||
{{ $t('bots.schedule.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -49,9 +56,17 @@
|
||||
class="size-6 text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
<p class="text-xs text-muted-foreground mb-3">
|
||||
{{ $t('bots.schedule.empty') }}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@click="handleNew"
|
||||
>
|
||||
<Plus class="mr-1 size-4" />
|
||||
{{ $t('bots.schedule.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
@@ -72,12 +87,12 @@
|
||||
<th class="px-4 py-2 text-left font-medium">
|
||||
{{ $t('bots.schedule.calls') }}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left font-medium">
|
||||
{{ $t('bots.schedule.createdAt') }}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-left font-medium">
|
||||
{{ $t('bots.schedule.updatedAt') }}
|
||||
</th>
|
||||
<th class="px-4 py-2 text-right font-medium w-[1%]">
|
||||
{{ $t('bots.schedule.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -86,31 +101,71 @@
|
||||
:key="item.id"
|
||||
class="border-b last:border-0 hover:bg-muted/30"
|
||||
>
|
||||
<td class="px-4 py-2 font-medium">
|
||||
<td class="px-4 py-2 font-medium align-top">
|
||||
<div>{{ item.name }}</div>
|
||||
<div class="text-xs text-muted-foreground line-clamp-1">
|
||||
{{ item.description }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">
|
||||
<td class="px-4 py-2 align-top">
|
||||
<code class="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
|
||||
{{ item.pattern }}
|
||||
</code>
|
||||
<div
|
||||
v-if="describeItem(item.pattern)"
|
||||
class="text-[11px] text-muted-foreground mt-1"
|
||||
>
|
||||
{{ describeItem(item.pattern) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2">
|
||||
<Badge :variant="item.enabled ? 'secondary' : 'outline'">
|
||||
{{ item.enabled ? $t('bots.schedule.statusEnabled') : $t('bots.schedule.statusDisabled') }}
|
||||
</Badge>
|
||||
<td class="px-4 py-2 align-top">
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="!!item.enabled"
|
||||
:disabled="busyIds.has(item.id || '')"
|
||||
@update:model-value="(val: boolean) => handleToggleEnabled(item, !!val)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ item.enabled ? $t('bots.schedule.statusEnabled') : $t('bots.schedule.statusDisabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-2 text-muted-foreground">
|
||||
{{ item.current_calls ?? 0 }} / {{ item.max_calls || $t('bots.schedule.unlimited') }}
|
||||
<td class="px-4 py-2 text-muted-foreground align-top">
|
||||
{{ item.current_calls ?? 0 }} / {{ formatMaxCalls(item) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-muted-foreground">
|
||||
{{ formatDateTime(item.created_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 text-muted-foreground">
|
||||
<td class="px-4 py-2 text-muted-foreground align-top">
|
||||
{{ formatDateTime(item.updated_at) }}
|
||||
</td>
|
||||
<td class="px-4 py-2 align-top text-right whitespace-nowrap">
|
||||
<div class="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="size-7"
|
||||
:aria-label="$t('bots.schedule.edit')"
|
||||
@click="handleEdit(item)"
|
||||
>
|
||||
<Pencil class="size-3.5" />
|
||||
</Button>
|
||||
<ConfirmPopover
|
||||
:message="$t('bots.schedule.deleteConfirm', { name: item.name })"
|
||||
:confirm-text="$t('bots.schedule.delete')"
|
||||
:loading="busyIds.has(item.id || '')"
|
||||
@confirm="handleDelete(item)"
|
||||
>
|
||||
<template #trigger>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
class="size-7 text-destructive hover:text-destructive"
|
||||
:aria-label="$t('bots.schedule.delete')"
|
||||
>
|
||||
<Trash2 class="size-3.5" />
|
||||
</Button>
|
||||
</template>
|
||||
</ConfirmPopover>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -154,35 +209,58 @@
|
||||
</Pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<ScheduleFormDialog
|
||||
v-model:open="dialogOpen"
|
||||
:bot-id="botId"
|
||||
:mode="dialogMode"
|
||||
:schedule="dialogSchedule"
|
||||
:timezone="botTimezone"
|
||||
@saved="handleSaved"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Calendar } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Calendar, Pencil, Plus, Trash2 } from 'lucide-vue-next'
|
||||
import { ref, computed, onMounted, reactive } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import {
|
||||
Button, Badge, Spinner,
|
||||
Button, Badge, Spinner, Switch,
|
||||
Pagination, PaginationContent, PaginationEllipsis,
|
||||
PaginationFirst, PaginationItem, PaginationLast,
|
||||
PaginationNext, PaginationPrevious,
|
||||
} from '@memohai/ui'
|
||||
import { getBotsByBotIdSchedule } from '@memohai/sdk'
|
||||
import {
|
||||
deleteBotsByBotIdScheduleById,
|
||||
getBotsByBotIdSchedule,
|
||||
getBotsByBotIdSettings,
|
||||
putBotsByBotIdScheduleById,
|
||||
} from '@memohai/sdk'
|
||||
import type { ScheduleSchedule } from '@memohai/sdk'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import { resolveApiErrorMessage } from '@/utils/api-error'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { describeCron } from '@/utils/cron-pattern'
|
||||
import ScheduleFormDialog from './schedule-form-dialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
const isLoading = ref(false)
|
||||
const schedules = ref<ScheduleSchedule[]>([])
|
||||
const currentPage = ref(1)
|
||||
const PAGE_SIZE = 10
|
||||
const botTimezone = ref<string | undefined>(undefined)
|
||||
const busyIds = reactive(new Set<string>())
|
||||
|
||||
const dialogOpen = ref(false)
|
||||
const dialogMode = ref<'create' | 'edit'>('create')
|
||||
const dialogSchedule = ref<ScheduleSchedule | null>(null)
|
||||
|
||||
const totalPages = computed(() => Math.ceil(schedules.value.length / PAGE_SIZE))
|
||||
|
||||
@@ -199,6 +277,21 @@ const paginationSummary = computed(() => {
|
||||
return `${start}-${end} / ${total}`
|
||||
})
|
||||
|
||||
const cronLocale = computed<'en' | 'zh'>(() => (locale.value.startsWith('zh') ? 'zh' : 'en'))
|
||||
|
||||
function describeItem(pattern: string | undefined): string | undefined {
|
||||
if (!pattern) return undefined
|
||||
return describeCron(pattern, cronLocale.value)
|
||||
}
|
||||
|
||||
function formatMaxCalls(item: ScheduleSchedule): string {
|
||||
// Backend emits max_calls as a plain number or omits it (typing in the SDK
|
||||
// is a known mismatch). Treat any non-positive/absent value as unlimited.
|
||||
const raw = item.max_calls as unknown
|
||||
if (typeof raw === 'number' && raw > 0) return String(raw)
|
||||
return t('bots.schedule.unlimited')
|
||||
}
|
||||
|
||||
async function fetchSchedules() {
|
||||
if (!props.botId) return
|
||||
isLoading.value = true
|
||||
@@ -215,12 +308,80 @@ async function fetchSchedules() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchBotSettings() {
|
||||
if (!props.botId) return
|
||||
try {
|
||||
const { data } = await getBotsByBotIdSettings({
|
||||
path: { bot_id: props.botId },
|
||||
throwOnError: true,
|
||||
})
|
||||
const tz = (data as { timezone?: string } | undefined)?.timezone
|
||||
botTimezone.value = tz && tz.trim() !== '' ? tz : undefined
|
||||
} catch {
|
||||
// Fallback to browser timezone — non-fatal.
|
||||
botTimezone.value = undefined
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
currentPage.value = 1
|
||||
await fetchSchedules()
|
||||
}
|
||||
|
||||
function handleNew() {
|
||||
dialogMode.value = 'create'
|
||||
dialogSchedule.value = null
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
function handleEdit(item: ScheduleSchedule) {
|
||||
dialogMode.value = 'edit'
|
||||
dialogSchedule.value = item
|
||||
dialogOpen.value = true
|
||||
}
|
||||
|
||||
async function handleSaved() {
|
||||
await fetchSchedules()
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(item: ScheduleSchedule, enabled: boolean) {
|
||||
const id = item.id
|
||||
if (!id) return
|
||||
busyIds.add(id)
|
||||
try {
|
||||
await putBotsByBotIdScheduleById({
|
||||
path: { bot_id: props.botId, id },
|
||||
body: { enabled },
|
||||
throwOnError: true,
|
||||
})
|
||||
await fetchSchedules()
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.schedule.saveFailed')))
|
||||
} finally {
|
||||
busyIds.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(item: ScheduleSchedule) {
|
||||
const id = item.id
|
||||
if (!id) return
|
||||
busyIds.add(id)
|
||||
try {
|
||||
await deleteBotsByBotIdScheduleById({
|
||||
path: { bot_id: props.botId, id },
|
||||
throwOnError: true,
|
||||
})
|
||||
toast.success(t('bots.schedule.deleteSuccess'))
|
||||
await fetchSchedules()
|
||||
} catch (error) {
|
||||
toast.error(resolveApiErrorMessage(error, t('bots.schedule.deleteFailed')))
|
||||
} finally {
|
||||
busyIds.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSchedules()
|
||||
fetchBotSettings()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
<template>
|
||||
<FormDialogShell
|
||||
v-model:open="open"
|
||||
:title="dialogTitle"
|
||||
:cancel-text="$t('common.cancel')"
|
||||
:submit-text="submitText"
|
||||
:submit-disabled="!canSubmit"
|
||||
:loading="isSaving"
|
||||
max-width-class="sm:max-w-[560px]"
|
||||
@submit="handleSubmit"
|
||||
>
|
||||
<template #body>
|
||||
<div class="mt-4 flex flex-col gap-4">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="space-y-1.5 flex-1 min-w-0">
|
||||
<Label for="schedule-name">
|
||||
{{ $t('bots.schedule.form.name') }}
|
||||
</Label>
|
||||
<Input
|
||||
id="schedule-name"
|
||||
v-model="form.name"
|
||||
:placeholder="$t('bots.schedule.form.namePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 h-9 shrink-0">
|
||||
<Label
|
||||
class="cursor-pointer text-xs"
|
||||
@click="form.enabled = !form.enabled"
|
||||
>
|
||||
{{ $t('bots.schedule.form.enabled') }}
|
||||
</Label>
|
||||
<Switch
|
||||
:model-value="form.enabled"
|
||||
@update:model-value="(v: boolean) => form.enabled = !!v"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label
|
||||
for="schedule-description"
|
||||
class="flex items-center gap-1.5"
|
||||
>
|
||||
{{ $t('bots.schedule.form.description') }}
|
||||
<span class="text-[11px] text-muted-foreground font-normal">
|
||||
({{ $t('common.optional') }})
|
||||
</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="schedule-description"
|
||||
v-model="form.description"
|
||||
:placeholder="$t('bots.schedule.form.descriptionPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label for="schedule-command">
|
||||
{{ $t('bots.schedule.form.command') }}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="schedule-command"
|
||||
v-model="form.command"
|
||||
class="text-xs"
|
||||
:placeholder="$t('bots.schedule.form.commandPlaceholder')"
|
||||
rows="3"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.schedule.form.commandHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<Label>{{ $t('bots.schedule.form.pattern') }}</Label>
|
||||
<SchedulePatternBuilder
|
||||
:state="patternState"
|
||||
:timezone="timezone"
|
||||
@update:state="(next) => patternState = next"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<div class="flex items-center justify-between">
|
||||
<Label>{{ $t('bots.schedule.form.maxCalls') }}</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Switch
|
||||
:model-value="maxCallsUnlimited"
|
||||
@update:model-value="(v: boolean) => handleMaxCallsUnlimited(!!v)"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.schedule.form.maxCallsUnlimited') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
v-if="!maxCallsUnlimited"
|
||||
:model-value="form.maxCalls ?? 1"
|
||||
type="number"
|
||||
:min="1"
|
||||
:placeholder="'1'"
|
||||
@update:model-value="(v) => form.maxCalls = Math.max(1, Math.floor(Number(v) || 1))"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="submitError"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
{{ submitError }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</FormDialogShell>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { Input, Label, Switch, Textarea } from '@memohai/ui'
|
||||
import {
|
||||
postBotsByBotIdSchedule,
|
||||
putBotsByBotIdScheduleById,
|
||||
type ScheduleCreateRequest,
|
||||
type ScheduleSchedule,
|
||||
type ScheduleUpdateRequest,
|
||||
} from '@memohai/sdk'
|
||||
import FormDialogShell from '@/components/form-dialog-shell/index.vue'
|
||||
import {
|
||||
defaultScheduleFormState,
|
||||
fromCron,
|
||||
isValidCron,
|
||||
toCron,
|
||||
type ScheduleFormState,
|
||||
} from '@/utils/cron-pattern'
|
||||
import SchedulePatternBuilder from './schedule-pattern-builder.vue'
|
||||
import { resolveApiErrorMessage } from '@/utils/api-error'
|
||||
|
||||
const props = defineProps<{
|
||||
botId: string
|
||||
mode: 'create' | 'edit'
|
||||
schedule?: ScheduleSchedule | null
|
||||
timezone?: string
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const emit = defineEmits<{
|
||||
saved: [schedule: ScheduleSchedule]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
interface SchedulePlainForm {
|
||||
name: string
|
||||
description: string
|
||||
command: string
|
||||
maxCalls: number | null
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
const form = reactive<SchedulePlainForm>({
|
||||
name: '',
|
||||
description: '',
|
||||
command: '',
|
||||
maxCalls: null,
|
||||
enabled: true,
|
||||
})
|
||||
|
||||
const patternState = ref<ScheduleFormState>(defaultScheduleFormState())
|
||||
const isSaving = ref(false)
|
||||
const submitError = ref<string | null>(null)
|
||||
|
||||
const dialogTitle = computed(() => props.mode === 'create'
|
||||
? t('bots.schedule.create')
|
||||
: t('bots.schedule.edit'))
|
||||
|
||||
const submitText = computed(() => props.mode === 'create'
|
||||
? t('common.create')
|
||||
: t('common.save'))
|
||||
|
||||
const maxCallsUnlimited = computed(() => form.maxCalls === null)
|
||||
|
||||
function handleMaxCallsUnlimited(v: boolean) {
|
||||
form.maxCalls = v ? null : 1
|
||||
}
|
||||
|
||||
const derivedPattern = computed(() => {
|
||||
try {
|
||||
return toCron(patternState.value).trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (isSaving.value) return false
|
||||
if (!form.name.trim()) return false
|
||||
if (!form.command.trim()) return false
|
||||
if (!derivedPattern.value) return false
|
||||
// Guard advanced mode: cron-parser must accept the raw text.
|
||||
if (patternState.value.mode === 'advanced' && !isValidCron(derivedPattern.value)) {
|
||||
return false
|
||||
}
|
||||
if (!maxCallsUnlimited.value && (form.maxCalls === null || form.maxCalls < 1)) return false
|
||||
return true
|
||||
})
|
||||
|
||||
function resetForNew() {
|
||||
form.name = ''
|
||||
form.description = ''
|
||||
form.command = ''
|
||||
form.maxCalls = null
|
||||
form.enabled = true
|
||||
patternState.value = defaultScheduleFormState()
|
||||
submitError.value = null
|
||||
}
|
||||
|
||||
function hydrateFromSchedule(s: ScheduleSchedule) {
|
||||
form.name = s.name ?? ''
|
||||
form.description = s.description ?? ''
|
||||
form.command = s.command ?? ''
|
||||
// See the note below handleSubmit: the SDK declares max_calls as
|
||||
// ScheduleNullableInt but the backend actually emits a plain number or
|
||||
// omits the field entirely. Read defensively.
|
||||
const maxCallsRaw = s.max_calls as unknown
|
||||
form.maxCalls = (typeof maxCallsRaw === 'number' && maxCallsRaw > 0) ? maxCallsRaw : null
|
||||
form.enabled = s.enabled ?? true
|
||||
patternState.value = fromCron(s.pattern ?? '')
|
||||
submitError.value = null
|
||||
}
|
||||
|
||||
// Re-initialise whenever the dialog opens so that reopening on a different row
|
||||
// picks up the new props. Edit mode always rehydrates from the current schedule
|
||||
// prop (the round-trip invariant guarantees a clean state identical to what
|
||||
// would be produced by building the same pattern in the UI).
|
||||
watch(open, (next) => {
|
||||
if (!next) return
|
||||
if (props.mode === 'edit' && props.schedule) {
|
||||
hydrateFromSchedule(props.schedule)
|
||||
} else {
|
||||
resetForNew()
|
||||
}
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!canSubmit.value) return
|
||||
submitError.value = null
|
||||
isSaving.value = true
|
||||
try {
|
||||
const pattern = derivedPattern.value
|
||||
// The SDK types max_calls as ScheduleNullableInt ({set, value}), but the
|
||||
// Go backend's custom (Un)MarshalJSON uses a plain nullable int on the
|
||||
// wire: either `null` (unlimited) or a raw integer. We cast through
|
||||
// unknown to bypass the mis-typed SDK declaration without lying about the
|
||||
// wire shape.
|
||||
const maxCallsWire = form.maxCalls ?? null
|
||||
if (props.mode === 'create') {
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
command: form.command.trim(),
|
||||
pattern,
|
||||
enabled: form.enabled,
|
||||
max_calls: maxCallsWire,
|
||||
} as unknown as ScheduleCreateRequest
|
||||
const { data } = await postBotsByBotIdSchedule({
|
||||
path: { bot_id: props.botId },
|
||||
body,
|
||||
throwOnError: true,
|
||||
})
|
||||
if (data) emit('saved', data)
|
||||
toast.success(t('bots.schedule.saveSuccess'))
|
||||
open.value = false
|
||||
} else {
|
||||
const id = props.schedule?.id
|
||||
if (!id) throw new Error('schedule id missing')
|
||||
const body = {
|
||||
name: form.name.trim(),
|
||||
description: form.description.trim(),
|
||||
command: form.command.trim(),
|
||||
pattern,
|
||||
enabled: form.enabled,
|
||||
max_calls: maxCallsWire,
|
||||
} as unknown as ScheduleUpdateRequest
|
||||
const { data } = await putBotsByBotIdScheduleById({
|
||||
path: { bot_id: props.botId, id },
|
||||
body,
|
||||
throwOnError: true,
|
||||
})
|
||||
if (data) emit('saved', data)
|
||||
toast.success(t('bots.schedule.saveSuccess'))
|
||||
open.value = false
|
||||
}
|
||||
} catch (err) {
|
||||
submitError.value = resolveApiErrorMessage(err, t('bots.schedule.saveFailed'))
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.mode') }}</Label>
|
||||
<NativeSelect
|
||||
v-model="modeModel"
|
||||
class="h-9 text-xs"
|
||||
>
|
||||
<option value="minutes">
|
||||
{{ $t('bots.schedule.mode.minutes') }}
|
||||
</option>
|
||||
<option value="hourly">
|
||||
{{ $t('bots.schedule.mode.hourly') }}
|
||||
</option>
|
||||
<option value="daily">
|
||||
{{ $t('bots.schedule.mode.daily') }}
|
||||
</option>
|
||||
<option value="weekly">
|
||||
{{ $t('bots.schedule.mode.weekly') }}
|
||||
</option>
|
||||
<option value="monthly">
|
||||
{{ $t('bots.schedule.mode.monthly') }}
|
||||
</option>
|
||||
<option value="yearly">
|
||||
{{ $t('bots.schedule.mode.yearly') }}
|
||||
</option>
|
||||
<option value="advanced">
|
||||
{{ $t('bots.schedule.mode.advanced') }}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ modeHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- minutes -->
|
||||
<div
|
||||
v-if="state.mode === 'minutes'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>{{ $t('bots.schedule.form.everyMinutes') }}</Label>
|
||||
<Input
|
||||
:model-value="state.intervalMinutes"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="59"
|
||||
@update:model-value="(v) => update({ intervalMinutes: clampInt(v, 1, 59, 1) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- hourly -->
|
||||
<div
|
||||
v-else-if="state.mode === 'hourly'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>{{ $t('bots.schedule.form.atMinute') }}</Label>
|
||||
<Input
|
||||
:model-value="state.minute"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="59"
|
||||
@update:model-value="(v) => update({ minute: clampInt(v, 0, 59, 0) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- daily -->
|
||||
<div
|
||||
v-else-if="state.mode === 'daily'"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.hours') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.schedule.form.hoursHint') }}
|
||||
</p>
|
||||
<div class="grid grid-cols-8 gap-1.5">
|
||||
<button
|
||||
v-for="h in 24"
|
||||
:key="h - 1"
|
||||
type="button"
|
||||
class="h-8 rounded-md border text-xs font-mono transition-colors"
|
||||
:class="state.hours.includes(h - 1)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent'"
|
||||
@click="toggleHour(h - 1)"
|
||||
>
|
||||
{{ pad2(h - 1) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.minute') }}</Label>
|
||||
<Input
|
||||
:model-value="state.minute"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="59"
|
||||
@update:model-value="(v) => update({ minute: clampInt(v, 0, 59, 0) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- weekly -->
|
||||
<div
|
||||
v-else-if="state.mode === 'weekly'"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.weekdays') }}</Label>
|
||||
<div class="grid grid-cols-7 gap-1.5">
|
||||
<button
|
||||
v-for="(key, idx) in WEEKDAY_KEYS"
|
||||
:key="key"
|
||||
type="button"
|
||||
class="h-9 rounded-md border text-xs transition-colors"
|
||||
:class="state.weekdays.includes(idx)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent'"
|
||||
@click="toggleWeekday(idx)"
|
||||
>
|
||||
{{ $t(`bots.schedule.weekday.${key}`) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.hour') }}</Label>
|
||||
<Input
|
||||
:model-value="singleHour"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="23"
|
||||
@update:model-value="(v) => setSingleHour(clampInt(v, 0, 23, 0))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.minute') }}</Label>
|
||||
<Input
|
||||
:model-value="state.minute"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="59"
|
||||
@update:model-value="(v) => update({ minute: clampInt(v, 0, 59, 0) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- monthly -->
|
||||
<div
|
||||
v-else-if="state.mode === 'monthly'"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.monthDays') }}</Label>
|
||||
<div class="grid grid-cols-7 gap-1.5">
|
||||
<button
|
||||
v-for="d in 31"
|
||||
:key="d"
|
||||
type="button"
|
||||
class="h-8 rounded-md border text-xs font-mono transition-colors"
|
||||
:class="state.monthDays.includes(d)
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-background hover:bg-accent'"
|
||||
@click="toggleMonthDay(d)"
|
||||
>
|
||||
{{ d }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.hour') }}</Label>
|
||||
<Input
|
||||
:model-value="singleHour"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="23"
|
||||
@update:model-value="(v) => setSingleHour(clampInt(v, 0, 23, 0))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.minute') }}</Label>
|
||||
<Input
|
||||
:model-value="state.minute"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="59"
|
||||
@update:model-value="(v) => update({ minute: clampInt(v, 0, 59, 0) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- yearly -->
|
||||
<div
|
||||
v-else-if="state.mode === 'yearly'"
|
||||
class="space-y-3"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.month') }}</Label>
|
||||
<NativeSelect
|
||||
v-model="yearlyMonthModel"
|
||||
class="h-9 text-xs"
|
||||
>
|
||||
<option
|
||||
v-for="(key, idx) in MONTH_KEYS"
|
||||
:key="key"
|
||||
:value="String(idx + 1)"
|
||||
>
|
||||
{{ $t(`bots.schedule.month.${key}`) }}
|
||||
</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.monthDay') }}</Label>
|
||||
<Input
|
||||
:model-value="state.monthDay"
|
||||
type="number"
|
||||
:min="1"
|
||||
:max="31"
|
||||
@update:model-value="(v) => update({ monthDay: clampInt(v, 1, 31, 1) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.hour') }}</Label>
|
||||
<Input
|
||||
:model-value="singleHour"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="23"
|
||||
@update:model-value="(v) => setSingleHour(clampInt(v, 0, 23, 0))"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.schedule.form.minute') }}</Label>
|
||||
<Input
|
||||
:model-value="state.minute"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="59"
|
||||
@update:model-value="(v) => update({ minute: clampInt(v, 0, 59, 0) })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- advanced -->
|
||||
<div
|
||||
v-else-if="state.mode === 'advanced'"
|
||||
class="space-y-2"
|
||||
>
|
||||
<Label>{{ $t('bots.schedule.form.advancedPattern') }}</Label>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.schedule.form.advancedHint') }}
|
||||
</p>
|
||||
<Input
|
||||
:model-value="state.advancedPattern"
|
||||
class="font-mono"
|
||||
:placeholder="'0 9 * * *'"
|
||||
@update:model-value="(v) => update({ advancedPattern: String(v) })"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- preview -->
|
||||
<div class="rounded-md border bg-muted/30 px-3 py-2 space-y-1.5">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.schedule.form.patternPreview') }}
|
||||
</div>
|
||||
<code
|
||||
v-if="previewPattern"
|
||||
class="text-xs font-mono break-all"
|
||||
>{{ previewPattern }}</code>
|
||||
<code
|
||||
v-else
|
||||
class="text-xs text-muted-foreground"
|
||||
>—</code>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-xs">
|
||||
<span
|
||||
v-if="humanText"
|
||||
class="text-foreground"
|
||||
>{{ humanText }}</span>
|
||||
<span
|
||||
v-else
|
||||
class="text-destructive"
|
||||
>{{ $t('bots.schedule.form.invalidPattern') }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="upcomingRuns.length"
|
||||
class="text-xs text-muted-foreground space-y-0.5 pt-1 border-t border-border/60"
|
||||
>
|
||||
<div>
|
||||
{{ $t('bots.schedule.form.nextRuns', { tz: effectiveTimezone }) }}
|
||||
</div>
|
||||
<div
|
||||
v-for="(d, i) in upcomingRuns"
|
||||
:key="i"
|
||||
class="font-mono"
|
||||
>
|
||||
· {{ formatPreviewDate(d) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Input, Label, NativeSelect } from '@memohai/ui'
|
||||
import {
|
||||
describeCron,
|
||||
nextRuns,
|
||||
toCron,
|
||||
MONTH_KEYS,
|
||||
WEEKDAY_KEYS,
|
||||
type CronLocale,
|
||||
type ScheduleFormState,
|
||||
type ScheduleMode,
|
||||
} from '@/utils/cron-pattern'
|
||||
|
||||
const props = defineProps<{
|
||||
state: ScheduleFormState
|
||||
timezone?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:state': [value: ScheduleFormState]
|
||||
}>()
|
||||
|
||||
const { locale, t } = useI18n()
|
||||
|
||||
const cronLocale = computed<CronLocale>(() => (locale.value.startsWith('zh') ? 'zh' : 'en'))
|
||||
|
||||
const effectiveTimezone = computed(() => {
|
||||
const tz = props.timezone?.trim()
|
||||
if (tz) return tz
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
} catch {
|
||||
return 'UTC'
|
||||
}
|
||||
})
|
||||
|
||||
function update(patch: Partial<ScheduleFormState>) {
|
||||
emit('update:state', { ...props.state, ...patch })
|
||||
}
|
||||
|
||||
// NativeSelect's v-model type allows any AcceptableValue, so we wrap writes
|
||||
// with string coercion and validated casts before updating form state.
|
||||
const modeModel = computed({
|
||||
get: (): string => props.state.mode,
|
||||
set: (val: unknown) => {
|
||||
const next = String(val)
|
||||
if (
|
||||
next === 'minutes' || next === 'hourly' || next === 'daily'
|
||||
|| next === 'weekly' || next === 'monthly' || next === 'yearly'
|
||||
|| next === 'advanced'
|
||||
) {
|
||||
handleModeChange(next)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const yearlyMonthModel = computed({
|
||||
get: (): string => String(props.state.month),
|
||||
set: (val: unknown) => {
|
||||
const n = Number(val)
|
||||
if (Number.isInteger(n) && n >= 1 && n <= 12) {
|
||||
update({ month: n })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function clampInt(value: unknown, min: number, max: number, fallback: number): number {
|
||||
const n = Number(value)
|
||||
if (!Number.isFinite(n)) return fallback
|
||||
return Math.max(min, Math.min(max, Math.round(n)))
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n.toString().padStart(2, '0')
|
||||
}
|
||||
|
||||
const singleHour = computed(() => props.state.hours[0] ?? 0)
|
||||
|
||||
function setSingleHour(h: number) {
|
||||
update({ hours: [h] })
|
||||
}
|
||||
|
||||
function toggleHour(h: number) {
|
||||
const set = new Set(props.state.hours)
|
||||
if (set.has(h)) set.delete(h)
|
||||
else set.add(h)
|
||||
const next = Array.from(set).sort((a, b) => a - b)
|
||||
update({ hours: next.length ? next : [h] })
|
||||
}
|
||||
|
||||
function toggleWeekday(d: number) {
|
||||
const set = new Set(props.state.weekdays)
|
||||
if (set.has(d)) set.delete(d)
|
||||
else set.add(d)
|
||||
const next = Array.from(set).sort((a, b) => a - b)
|
||||
update({ weekdays: next.length ? next : [d] })
|
||||
}
|
||||
|
||||
function toggleMonthDay(d: number) {
|
||||
const set = new Set(props.state.monthDays)
|
||||
if (set.has(d)) set.delete(d)
|
||||
else set.add(d)
|
||||
const next = Array.from(set).sort((a, b) => a - b)
|
||||
update({ monthDays: next.length ? next : [d] })
|
||||
}
|
||||
|
||||
function handleModeChange(next: ScheduleMode) {
|
||||
const patch: Partial<ScheduleFormState> = { mode: next }
|
||||
// Normalize state when switching to modes that require a single hour, so the
|
||||
// builder stays internally consistent.
|
||||
if (next === 'weekly' || next === 'monthly' || next === 'yearly' || next === 'hourly') {
|
||||
patch.hours = [props.state.hours[0] ?? 9]
|
||||
}
|
||||
if (next === 'advanced' && !props.state.advancedPattern.trim()) {
|
||||
// Seed the advanced input with the currently-derived pattern so the user
|
||||
// can start from a known-good expression instead of a blank field.
|
||||
try {
|
||||
patch.advancedPattern = toCron(props.state)
|
||||
} catch {
|
||||
patch.advancedPattern = ''
|
||||
}
|
||||
}
|
||||
emit('update:state', { ...props.state, ...patch })
|
||||
}
|
||||
|
||||
const previewPattern = computed(() => {
|
||||
try {
|
||||
const p = toCron(props.state)
|
||||
return p.trim()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
const humanText = computed(() => {
|
||||
if (!previewPattern.value) return undefined
|
||||
return describeCron(previewPattern.value, cronLocale.value)
|
||||
})
|
||||
|
||||
const upcomingRuns = computed(() => {
|
||||
if (!previewPattern.value) return []
|
||||
return nextRuns(previewPattern.value, effectiveTimezone.value, 3)
|
||||
})
|
||||
|
||||
const modeHint = computed(() => t(`bots.schedule.modeHint.${props.state.mode}`))
|
||||
|
||||
const previewFormatter = computed(() => new Intl.DateTimeFormat(
|
||||
locale.value.startsWith('zh') ? 'zh-CN' : 'en-US',
|
||||
{
|
||||
timeZone: effectiveTimezone.value,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
},
|
||||
))
|
||||
|
||||
function formatPreviewDate(d: Date): string {
|
||||
return previewFormatter.value.format(d)
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,218 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
defaultScheduleFormState,
|
||||
describeCron,
|
||||
fromCron,
|
||||
isValidCron,
|
||||
nextRuns,
|
||||
toCron,
|
||||
type ScheduleFormState,
|
||||
} from './cron-pattern'
|
||||
|
||||
function mk(overrides: Partial<ScheduleFormState>): ScheduleFormState {
|
||||
return { ...defaultScheduleFormState(), ...overrides }
|
||||
}
|
||||
|
||||
describe('toCron', () => {
|
||||
it('minutes mode', () => {
|
||||
expect(toCron(mk({ mode: 'minutes', intervalMinutes: 15 }))).toBe('*/15 * * * *')
|
||||
})
|
||||
|
||||
it('hourly mode', () => {
|
||||
expect(toCron(mk({ mode: 'hourly', minute: 30 }))).toBe('30 * * * *')
|
||||
})
|
||||
|
||||
it('daily mode single hour', () => {
|
||||
expect(toCron(mk({ mode: 'daily', minute: 0, hours: [9] }))).toBe('0 9 * * *')
|
||||
})
|
||||
|
||||
it('daily mode multi hour gets sorted & deduped', () => {
|
||||
expect(toCron(mk({ mode: 'daily', minute: 30, hours: [18, 9, 9, 13] })))
|
||||
.toBe('30 9,13,18 * * *')
|
||||
})
|
||||
|
||||
it('weekly mode', () => {
|
||||
expect(toCron(mk({
|
||||
mode: 'weekly',
|
||||
minute: 0,
|
||||
hours: [9],
|
||||
weekdays: [1, 3, 5],
|
||||
}))).toBe('0 9 * * 1,3,5')
|
||||
})
|
||||
|
||||
it('monthly mode', () => {
|
||||
expect(toCron(mk({
|
||||
mode: 'monthly',
|
||||
minute: 0,
|
||||
hours: [9],
|
||||
monthDays: [1, 15],
|
||||
}))).toBe('0 9 1,15 * *')
|
||||
})
|
||||
|
||||
it('yearly mode', () => {
|
||||
expect(toCron(mk({
|
||||
mode: 'yearly',
|
||||
minute: 0,
|
||||
hours: [12],
|
||||
month: 12,
|
||||
monthDay: 25,
|
||||
}))).toBe('0 12 25 12 *')
|
||||
})
|
||||
|
||||
it('advanced mode passes through trimmed', () => {
|
||||
expect(toCron(mk({ mode: 'advanced', advancedPattern: ' @daily ' }))).toBe('@daily')
|
||||
})
|
||||
|
||||
it('rejects out-of-range values', () => {
|
||||
expect(() => toCron(mk({ mode: 'minutes', intervalMinutes: 60 }))).toThrow()
|
||||
expect(() => toCron(mk({ mode: 'hourly', minute: 60 }))).toThrow()
|
||||
expect(() => toCron(mk({ mode: 'daily', minute: 0, hours: [24] }))).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('fromCron', () => {
|
||||
it('recognises minutes mode', () => {
|
||||
const s = fromCron('*/15 * * * *')
|
||||
expect(s.mode).toBe('minutes')
|
||||
expect(s.intervalMinutes).toBe(15)
|
||||
})
|
||||
|
||||
it('recognises hourly mode', () => {
|
||||
const s = fromCron('30 * * * *')
|
||||
expect(s.mode).toBe('hourly')
|
||||
expect(s.minute).toBe(30)
|
||||
})
|
||||
|
||||
it('recognises daily mode with multiple hours', () => {
|
||||
const s = fromCron('30 9,13,18 * * *')
|
||||
expect(s.mode).toBe('daily')
|
||||
expect(s.minute).toBe(30)
|
||||
expect(s.hours).toEqual([9, 13, 18])
|
||||
})
|
||||
|
||||
it('recognises weekly mode', () => {
|
||||
const s = fromCron('0 9 * * 1,3,5')
|
||||
expect(s.mode).toBe('weekly')
|
||||
expect(s.minute).toBe(0)
|
||||
expect(s.hours).toEqual([9])
|
||||
expect(s.weekdays).toEqual([1, 3, 5])
|
||||
})
|
||||
|
||||
it('recognises monthly mode', () => {
|
||||
const s = fromCron('0 9 1,15 * *')
|
||||
expect(s.mode).toBe('monthly')
|
||||
expect(s.monthDays).toEqual([1, 15])
|
||||
expect(s.hours).toEqual([9])
|
||||
})
|
||||
|
||||
it('recognises yearly mode', () => {
|
||||
const s = fromCron('0 12 25 12 *')
|
||||
expect(s.mode).toBe('yearly')
|
||||
expect(s.month).toBe(12)
|
||||
expect(s.monthDay).toBe(25)
|
||||
expect(s.hours).toEqual([12])
|
||||
expect(s.minute).toBe(0)
|
||||
})
|
||||
|
||||
it('falls back to advanced for descriptors', () => {
|
||||
const s = fromCron('@daily')
|
||||
expect(s.mode).toBe('advanced')
|
||||
expect(s.advancedPattern).toBe('@daily')
|
||||
})
|
||||
|
||||
it('falls back to advanced for 6-field cron', () => {
|
||||
const s = fromCron('0 */5 * * * *')
|
||||
expect(s.mode).toBe('advanced')
|
||||
expect(s.advancedPattern).toBe('0 */5 * * * *')
|
||||
})
|
||||
|
||||
it('falls back to advanced for range expressions', () => {
|
||||
const s = fromCron('30 9 1-15 * *')
|
||||
expect(s.mode).toBe('advanced')
|
||||
expect(s.advancedPattern).toBe('30 9 1-15 * *')
|
||||
})
|
||||
|
||||
it('falls back to advanced for step in hour field', () => {
|
||||
const s = fromCron('0 */2 * * *')
|
||||
expect(s.mode).toBe('advanced')
|
||||
})
|
||||
|
||||
it('falls back to advanced for named weekdays', () => {
|
||||
const s = fromCron('0 9 * * MON-FRI')
|
||||
expect(s.mode).toBe('advanced')
|
||||
})
|
||||
|
||||
it('falls back to advanced for empty input', () => {
|
||||
const s = fromCron(' ')
|
||||
expect(s.mode).toBe('advanced')
|
||||
expect(s.advancedPattern).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('round-trip fromCron(toCron(state))', () => {
|
||||
const cases: Array<{ label: string, state: ScheduleFormState }> = [
|
||||
{ label: 'minutes', state: mk({ mode: 'minutes', intervalMinutes: 5 }) },
|
||||
{ label: 'hourly', state: mk({ mode: 'hourly', minute: 45 }) },
|
||||
{ label: 'daily single', state: mk({ mode: 'daily', minute: 0, hours: [9] }) },
|
||||
{ label: 'daily multi', state: mk({ mode: 'daily', minute: 30, hours: [9, 13, 18] }) },
|
||||
{ label: 'weekly', state: mk({ mode: 'weekly', minute: 0, hours: [9], weekdays: [1, 3, 5] }) },
|
||||
{ label: 'monthly', state: mk({ mode: 'monthly', minute: 0, hours: [9], monthDays: [1, 15] }) },
|
||||
{ label: 'yearly', state: mk({ mode: 'yearly', minute: 0, hours: [12], month: 12, monthDay: 25 }) },
|
||||
]
|
||||
|
||||
for (const { label, state } of cases) {
|
||||
it(label, () => {
|
||||
const pattern = toCron(state)
|
||||
const parsed = fromCron(pattern)
|
||||
expect(parsed.mode).toBe(state.mode)
|
||||
// Re-emit and compare canonical strings to confirm no drift.
|
||||
expect(toCron(parsed)).toBe(pattern)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
describe('describeCron', () => {
|
||||
it('returns an english description', () => {
|
||||
const out = describeCron('0 9 * * *', 'en')
|
||||
expect(out).toBeTruthy()
|
||||
expect(out!.toLowerCase()).toContain('9')
|
||||
})
|
||||
|
||||
it('returns a chinese description', () => {
|
||||
const out = describeCron('0 9 * * *', 'zh')
|
||||
expect(out).toBeTruthy()
|
||||
})
|
||||
|
||||
it('returns undefined for invalid', () => {
|
||||
expect(describeCron('not a cron', 'en')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('nextRuns', () => {
|
||||
it('returns requested number of dates for valid pattern', () => {
|
||||
const runs = nextRuns('0 9 * * *', 'UTC', 3)
|
||||
expect(runs).toHaveLength(3)
|
||||
for (const d of runs) {
|
||||
expect(d.getUTCHours()).toBe(9)
|
||||
}
|
||||
})
|
||||
|
||||
it('returns empty for invalid pattern', () => {
|
||||
expect(nextRuns('not valid', 'UTC', 3)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isValidCron', () => {
|
||||
it('accepts 5-field cron', () => {
|
||||
expect(isValidCron('0 9 * * *')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts descriptors', () => {
|
||||
expect(isValidCron('@daily')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects garbage', () => {
|
||||
expect(isValidCron('')).toBe(false)
|
||||
expect(isValidCron('hello world')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,311 @@
|
||||
import cronstrue from 'cronstrue'
|
||||
import 'cronstrue/locales/zh_CN'
|
||||
import { CronExpressionParser } from 'cron-parser'
|
||||
|
||||
export type ScheduleMode =
|
||||
| 'minutes'
|
||||
| 'hourly'
|
||||
| 'daily'
|
||||
| 'weekly'
|
||||
| 'monthly'
|
||||
| 'yearly'
|
||||
| 'advanced'
|
||||
|
||||
export interface ScheduleFormState {
|
||||
mode: ScheduleMode
|
||||
intervalMinutes: number
|
||||
minute: number
|
||||
hours: number[]
|
||||
weekdays: number[]
|
||||
monthDays: number[]
|
||||
month: number
|
||||
monthDay: number
|
||||
advancedPattern: string
|
||||
}
|
||||
|
||||
// Default form state used when creating a new schedule. Chosen so that a user
|
||||
// who simply clicks "create" and hits save gets a reasonable daily-at-09:00
|
||||
// pattern.
|
||||
export function defaultScheduleFormState(): ScheduleFormState {
|
||||
return {
|
||||
mode: 'daily',
|
||||
intervalMinutes: 30,
|
||||
minute: 0,
|
||||
hours: [9],
|
||||
weekdays: [1, 2, 3, 4, 5],
|
||||
monthDays: [1],
|
||||
month: 1,
|
||||
monthDay: 1,
|
||||
advancedPattern: '',
|
||||
}
|
||||
}
|
||||
|
||||
function assertInt(value: number, min: number, max: number, label: string) {
|
||||
if (!Number.isInteger(value) || value < min || value > max) {
|
||||
throw new Error(`${label} must be an integer in [${min}, ${max}], got ${value}`)
|
||||
}
|
||||
}
|
||||
|
||||
function dedupSort(values: number[]): number[] {
|
||||
return Array.from(new Set(values)).sort((a, b) => a - b)
|
||||
}
|
||||
|
||||
function formatList(values: number[]): string {
|
||||
const normalized = dedupSort(values)
|
||||
if (normalized.length === 0) throw new Error('list cannot be empty')
|
||||
return normalized.join(',')
|
||||
}
|
||||
|
||||
// Produce a canonical 5-field cron pattern from the form state. Always returns
|
||||
// the standard `minute hour dom month dow` shape (never seconds, never
|
||||
// descriptors) so that fromCron can always recognise outputs of toCron and
|
||||
// round-trip to the same state.
|
||||
export function toCron(state: ScheduleFormState): string {
|
||||
switch (state.mode) {
|
||||
case 'minutes': {
|
||||
assertInt(state.intervalMinutes, 1, 59, 'intervalMinutes')
|
||||
return `*/${state.intervalMinutes} * * * *`
|
||||
}
|
||||
case 'hourly': {
|
||||
assertInt(state.minute, 0, 59, 'minute')
|
||||
return `${state.minute} * * * *`
|
||||
}
|
||||
case 'daily': {
|
||||
assertInt(state.minute, 0, 59, 'minute')
|
||||
state.hours.forEach(h => assertInt(h, 0, 23, 'hour'))
|
||||
const hourField = formatList(state.hours)
|
||||
return `${state.minute} ${hourField} * * *`
|
||||
}
|
||||
case 'weekly': {
|
||||
assertInt(state.minute, 0, 59, 'minute')
|
||||
if (state.hours.length !== 1) throw new Error('weekly mode requires a single hour')
|
||||
assertInt(state.hours[0]!, 0, 23, 'hour')
|
||||
state.weekdays.forEach(d => assertInt(d, 0, 6, 'weekday'))
|
||||
const dowField = formatList(state.weekdays)
|
||||
return `${state.minute} ${state.hours[0]} * * ${dowField}`
|
||||
}
|
||||
case 'monthly': {
|
||||
assertInt(state.minute, 0, 59, 'minute')
|
||||
if (state.hours.length !== 1) throw new Error('monthly mode requires a single hour')
|
||||
assertInt(state.hours[0]!, 0, 23, 'hour')
|
||||
state.monthDays.forEach(d => assertInt(d, 1, 31, 'monthDay'))
|
||||
const domField = formatList(state.monthDays)
|
||||
return `${state.minute} ${state.hours[0]} ${domField} * *`
|
||||
}
|
||||
case 'yearly': {
|
||||
assertInt(state.minute, 0, 59, 'minute')
|
||||
if (state.hours.length !== 1) throw new Error('yearly mode requires a single hour')
|
||||
assertInt(state.hours[0]!, 0, 23, 'hour')
|
||||
assertInt(state.month, 1, 12, 'month')
|
||||
assertInt(state.monthDay, 1, 31, 'monthDay')
|
||||
return `${state.minute} ${state.hours[0]} ${state.monthDay} ${state.month} *`
|
||||
}
|
||||
case 'advanced':
|
||||
return state.advancedPattern.trim()
|
||||
}
|
||||
}
|
||||
|
||||
// --- fromCron helpers ---------------------------------------------------------
|
||||
|
||||
// Strictly match "*", returning true. No range/step tolerance — we want
|
||||
// lossless round-trips only.
|
||||
function isStar(field: string): boolean {
|
||||
return field === '*'
|
||||
}
|
||||
|
||||
// Match a single non-negative integer. Returns undefined if not a plain int.
|
||||
function parseIntField(field: string): number | undefined {
|
||||
if (!/^\d+$/.test(field)) return undefined
|
||||
return Number(field)
|
||||
}
|
||||
|
||||
// Match a plain integer list "a,b,c" (no ranges, no steps). Returns sorted
|
||||
// unique numbers, or undefined on any non-conforming input.
|
||||
function parseIntList(field: string): number[] | undefined {
|
||||
if (field === '') return undefined
|
||||
const parts = field.split(',')
|
||||
const out: number[] = []
|
||||
for (const part of parts) {
|
||||
const n = parseIntField(part)
|
||||
if (n === undefined) return undefined
|
||||
out.push(n)
|
||||
}
|
||||
return dedupSort(out)
|
||||
}
|
||||
|
||||
function parseStep(field: string): number | undefined {
|
||||
const m = /^\*\/(\d+)$/.exec(field)
|
||||
if (!m) return undefined
|
||||
const n = Number(m[1])
|
||||
return Number.isInteger(n) ? n : undefined
|
||||
}
|
||||
|
||||
function inRange(values: number[], min: number, max: number): boolean {
|
||||
return values.every(v => v >= min && v <= max)
|
||||
}
|
||||
|
||||
// Parse a stored pattern back into form state. Any pattern that toCron could
|
||||
// not have produced (descriptors, 6-field seconds cron, ranges, steps other
|
||||
// than the minutes mode, named day-of-week tokens, etc.) falls back to
|
||||
// 'advanced' with the raw text preserved. This is intentional — lossy
|
||||
// recognition would let the builder UI silently rewrite the AI-generated
|
||||
// pattern on edit.
|
||||
export function fromCron(pattern: string): ScheduleFormState {
|
||||
const base = defaultScheduleFormState()
|
||||
const raw = pattern.trim()
|
||||
const advanced: ScheduleFormState = { ...base, mode: 'advanced', advancedPattern: raw }
|
||||
if (!raw) return advanced
|
||||
|
||||
// Descriptors (@daily, @every 1h, ...) and seconds cron have 1 or 6
|
||||
// space-separated tokens respectively; only 5-field standard cron maps to
|
||||
// structured modes.
|
||||
const fields = raw.split(/\s+/)
|
||||
if (fields.length !== 5) return advanced
|
||||
|
||||
const [minuteF, hourF, domF, monthF, dowF] = fields as [string, string, string, string, string]
|
||||
|
||||
// minutes: */N * * * *
|
||||
{
|
||||
const step = parseStep(minuteF)
|
||||
if (step !== undefined && isStar(hourF) && isStar(domF) && isStar(monthF) && isStar(dowF)) {
|
||||
if (step >= 1 && step <= 59) {
|
||||
return { ...base, mode: 'minutes', intervalMinutes: step }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hourly: M * * * *
|
||||
{
|
||||
const m = parseIntField(minuteF)
|
||||
if (m !== undefined && isStar(hourF) && isStar(domF) && isStar(monthF) && isStar(dowF)) {
|
||||
if (m >= 0 && m <= 59) {
|
||||
return { ...base, mode: 'hourly', minute: m }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// daily: M H[,H] * * *
|
||||
{
|
||||
const m = parseIntField(minuteF)
|
||||
const hours = parseIntList(hourF)
|
||||
if (
|
||||
m !== undefined && m >= 0 && m <= 59
|
||||
&& hours && hours.length > 0 && inRange(hours, 0, 23)
|
||||
&& isStar(domF) && isStar(monthF) && isStar(dowF)
|
||||
) {
|
||||
return { ...base, mode: 'daily', minute: m, hours }
|
||||
}
|
||||
}
|
||||
|
||||
// weekly: M H * * DOW[,DOW]
|
||||
{
|
||||
const m = parseIntField(minuteF)
|
||||
const h = parseIntField(hourF)
|
||||
const weekdays = parseIntList(dowF)
|
||||
if (
|
||||
m !== undefined && m >= 0 && m <= 59
|
||||
&& h !== undefined && h >= 0 && h <= 23
|
||||
&& isStar(domF) && isStar(monthF)
|
||||
&& weekdays && weekdays.length > 0 && inRange(weekdays, 0, 6)
|
||||
) {
|
||||
return { ...base, mode: 'weekly', minute: m, hours: [h], weekdays }
|
||||
}
|
||||
}
|
||||
|
||||
// monthly: M H D[,D] * *
|
||||
{
|
||||
const m = parseIntField(minuteF)
|
||||
const h = parseIntField(hourF)
|
||||
const monthDays = parseIntList(domF)
|
||||
if (
|
||||
m !== undefined && m >= 0 && m <= 59
|
||||
&& h !== undefined && h >= 0 && h <= 23
|
||||
&& monthDays && monthDays.length > 0 && inRange(monthDays, 1, 31)
|
||||
&& isStar(monthF) && isStar(dowF)
|
||||
) {
|
||||
return { ...base, mode: 'monthly', minute: m, hours: [h], monthDays }
|
||||
}
|
||||
}
|
||||
|
||||
// yearly: M H D Mo *
|
||||
{
|
||||
const m = parseIntField(minuteF)
|
||||
const h = parseIntField(hourF)
|
||||
const d = parseIntField(domF)
|
||||
const mo = parseIntField(monthF)
|
||||
if (
|
||||
m !== undefined && m >= 0 && m <= 59
|
||||
&& h !== undefined && h >= 0 && h <= 23
|
||||
&& d !== undefined && d >= 1 && d <= 31
|
||||
&& mo !== undefined && mo >= 1 && mo <= 12
|
||||
&& isStar(dowF)
|
||||
) {
|
||||
return { ...base, mode: 'yearly', minute: m, hours: [h], month: mo, monthDay: d }
|
||||
}
|
||||
}
|
||||
|
||||
return advanced
|
||||
}
|
||||
|
||||
// --- preview helpers ---------------------------------------------------------
|
||||
|
||||
export type CronLocale = 'en' | 'zh'
|
||||
|
||||
// Returns a localized human-readable description of the cron pattern, or
|
||||
// undefined if cronstrue cannot parse it.
|
||||
export function describeCron(pattern: string, locale: CronLocale): string | undefined {
|
||||
const trimmed = pattern.trim()
|
||||
if (!trimmed) return undefined
|
||||
try {
|
||||
return cronstrue.toString(trimmed, {
|
||||
locale: locale === 'zh' ? 'zh_CN' : 'en',
|
||||
use24HourTimeFormat: true,
|
||||
throwExceptionOnParseError: true,
|
||||
verbose: false,
|
||||
})
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the next `count` trigger dates for the given pattern, evaluated in
|
||||
// the provided IANA timezone. Returns an empty array on parse failure.
|
||||
export function nextRuns(pattern: string, timezone: string | undefined, count: number): Date[] {
|
||||
const trimmed = pattern.trim()
|
||||
if (!trimmed) return []
|
||||
try {
|
||||
const tz = timezone && timezone.trim() !== '' ? timezone : undefined
|
||||
const iter = CronExpressionParser.parse(trimmed, tz ? { tz } : {})
|
||||
const out: Date[] = []
|
||||
for (let i = 0; i < count; i++) {
|
||||
const d = iter.next().toDate()
|
||||
out.push(d)
|
||||
}
|
||||
return out
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Returns true iff `pattern` can be parsed by cron-parser. Used to guard
|
||||
// submission of the 'advanced' mode.
|
||||
export function isValidCron(pattern: string): boolean {
|
||||
const trimmed = pattern.trim()
|
||||
if (!trimmed) return false
|
||||
try {
|
||||
CronExpressionParser.parse(trimmed)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Localized weekday/month labels. 0 = Sunday per ISO cron convention.
|
||||
export const WEEKDAY_KEYS = [
|
||||
'sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
|
||||
] as const
|
||||
|
||||
export const MONTH_KEYS = [
|
||||
'jan', 'feb', 'mar', 'apr', 'may', 'jun',
|
||||
'jul', 'aug', 'sep', 'oct', 'nov', 'dec',
|
||||
] as const
|
||||
Generated
+26
@@ -134,6 +134,12 @@ importers:
|
||||
animate.css:
|
||||
specifier: ^4.1.1
|
||||
version: 4.1.1
|
||||
cron-parser:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0
|
||||
cronstrue:
|
||||
specifier: ^3.14.0
|
||||
version: 3.14.0
|
||||
dotenv:
|
||||
specifier: ^17.2.3
|
||||
version: 17.2.3
|
||||
@@ -2788,6 +2794,14 @@ packages:
|
||||
cose-base@2.2.0:
|
||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||
|
||||
cron-parser@5.5.0:
|
||||
resolution: {integrity: sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
cronstrue@3.14.0:
|
||||
resolution: {integrity: sha512-XnW4vuK/jPJjmTyDWiej1Zq36Od7ITwxaV2O1pzHZuyMVvdy7NAvyvIBzybt+idqSpfqYuoDG7uf/ocGtJVWxA==}
|
||||
hasBin: true
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -3703,6 +3717,10 @@ packages:
|
||||
peerDependencies:
|
||||
vue: '>=3.0.1'
|
||||
|
||||
luxon@3.7.2:
|
||||
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
@@ -7183,6 +7201,12 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
cron-parser@5.5.0:
|
||||
dependencies:
|
||||
luxon: 3.7.2
|
||||
|
||||
cronstrue@3.14.0: {}
|
||||
|
||||
cross-spawn@7.0.6:
|
||||
dependencies:
|
||||
path-key: 3.1.1
|
||||
@@ -8197,6 +8221,8 @@ snapshots:
|
||||
dependencies:
|
||||
vue: 3.5.26(typescript@5.9.3)
|
||||
|
||||
luxon@3.7.2: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
@@ -8,6 +8,7 @@ export default defineConfig({
|
||||
globals: true,
|
||||
include: [
|
||||
'packages/**/*.test.ts',
|
||||
'apps/**/*.test.ts',
|
||||
],
|
||||
env: process.env,
|
||||
testTimeout: Infinity,
|
||||
|
||||
Reference in New Issue
Block a user