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:
Acbox
2026-04-23 19:36:25 +08:00
parent 7642cb8ca4
commit defddc2257
10 changed files with 1685 additions and 24 deletions
+2
View File
@@ -23,6 +23,8 @@
"@xterm/addon-serialize": "^0.14.0", "@xterm/addon-serialize": "^0.14.0",
"@xterm/xterm": "^6.0.0", "@xterm/xterm": "^6.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
"cron-parser": "^5.5.0",
"cronstrue": "^3.14.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"echarts": "^6.0.0", "echarts": "^6.0.0",
"katex": "^0.16.28", "katex": "^0.16.28",
+83 -1
View File
@@ -4,6 +4,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"back": "Back", "back": "Back",
"save": "Save", "save": "Save",
"create": "Create",
"add": "Add", "add": "Add",
"edit": "Edit", "edit": "Edit",
"delete": "Delete", "delete": "Delete",
@@ -972,6 +973,8 @@
"compactionModel": "Compaction Model", "compactionModel": "Compaction Model",
"compactionModelDescription": "Select a model for summarization. Defaults to the bot's chat model if not set.", "compactionModelDescription": "Select a model for summarization. Defaults to the bot's chat model if not set.",
"compactionModelPlaceholder": "Use chat model (default)", "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", "browserContext": "Browser Context",
"browserContextPlaceholder": "Select browser context (disabled if empty)", "browserContextPlaceholder": "Select browser context (disabled if empty)",
"allowGuest": "Default ACL Effect", "allowGuest": "Default ACL Effect",
@@ -1240,7 +1243,86 @@
"updatedAt": "Updated", "updatedAt": "Updated",
"statusEnabled": "Enabled", "statusEnabled": "Enabled",
"statusDisabled": "Disabled", "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": { "history": {
"title": "History", "title": "History",
+83 -1
View File
@@ -4,6 +4,7 @@
"cancel": "取消", "cancel": "取消",
"back": "返回", "back": "返回",
"save": "保存", "save": "保存",
"create": "创建",
"add": "添加", "add": "添加",
"edit": "编辑", "edit": "编辑",
"delete": "删除", "delete": "删除",
@@ -968,6 +969,8 @@
"compactionModel": "压缩模型", "compactionModel": "压缩模型",
"compactionModelDescription": "选择用于摘要的模型,未设置时默认使用聊天模型。", "compactionModelDescription": "选择用于摘要的模型,未设置时默认使用聊天模型。",
"compactionModelPlaceholder": "使用聊天模型(默认)", "compactionModelPlaceholder": "使用聊天模型(默认)",
"showToolCallsInIM": "在 IM 中显示工具调用",
"showToolCallsInIMDescription": "在 IM 频道中以单条消息持续展示工具执行的状态(进行中 / 已完成 / 失败)。默认关闭。",
"browserContext": "浏览器上下文", "browserContext": "浏览器上下文",
"browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)", "browserContextPlaceholder": "选择浏览器上下文(未配置时不启用)",
"allowGuest": "ACL 默认行为", "allowGuest": "ACL 默认行为",
@@ -1236,7 +1239,86 @@
"updatedAt": "更新时间", "updatedAt": "更新时间",
"statusEnabled": "已启用", "statusEnabled": "已启用",
"statusDisabled": "已禁用", "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": { "history": {
"title": "对话历史", "title": "对话历史",
@@ -27,6 +27,13 @@
/> />
{{ $t('common.refresh') }} {{ $t('common.refresh') }}
</Button> </Button>
<Button
size="sm"
@click="handleNew"
>
<Plus class="mr-1 size-4" />
{{ $t('bots.schedule.create') }}
</Button>
</div> </div>
</div> </div>
@@ -49,9 +56,17 @@
class="size-6 text-muted-foreground" class="size-6 text-muted-foreground"
/> />
</div> </div>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground mb-3">
{{ $t('bots.schedule.empty') }} {{ $t('bots.schedule.empty') }}
</p> </p>
<Button
size="sm"
variant="outline"
@click="handleNew"
>
<Plus class="mr-1 size-4" />
{{ $t('bots.schedule.create') }}
</Button>
</div> </div>
<!-- Table --> <!-- Table -->
@@ -72,12 +87,12 @@
<th class="px-4 py-2 text-left font-medium"> <th class="px-4 py-2 text-left font-medium">
{{ $t('bots.schedule.calls') }} {{ $t('bots.schedule.calls') }}
</th> </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"> <th class="px-4 py-2 text-left font-medium">
{{ $t('bots.schedule.updatedAt') }} {{ $t('bots.schedule.updatedAt') }}
</th> </th>
<th class="px-4 py-2 text-right font-medium w-[1%]">
{{ $t('bots.schedule.actions') }}
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -86,31 +101,71 @@
:key="item.id" :key="item.id"
class="border-b last:border-0 hover:bg-muted/30" 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>{{ item.name }}</div>
<div class="text-xs text-muted-foreground line-clamp-1"> <div class="text-xs text-muted-foreground line-clamp-1">
{{ item.description }} {{ item.description }}
</div> </div>
</td> </td>
<td class="px-4 py-2"> <td class="px-4 py-2 align-top">
<code class="text-xs bg-muted px-1.5 py-0.5 rounded"> <code class="text-xs bg-muted px-1.5 py-0.5 rounded font-mono">
{{ item.pattern }} {{ item.pattern }}
</code> </code>
<div
v-if="describeItem(item.pattern)"
class="text-[11px] text-muted-foreground mt-1"
>
{{ describeItem(item.pattern) }}
</div>
</td> </td>
<td class="px-4 py-2"> <td class="px-4 py-2 align-top">
<Badge :variant="item.enabled ? 'secondary' : 'outline'"> <div class="flex items-center gap-2">
{{ item.enabled ? $t('bots.schedule.statusEnabled') : $t('bots.schedule.statusDisabled') }} <Switch
</Badge> :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>
<td class="px-4 py-2 text-muted-foreground"> <td class="px-4 py-2 text-muted-foreground align-top">
{{ item.current_calls ?? 0 }} / {{ item.max_calls || $t('bots.schedule.unlimited') }} {{ item.current_calls ?? 0 }} / {{ formatMaxCalls(item) }}
</td> </td>
<td class="px-4 py-2 text-muted-foreground"> <td class="px-4 py-2 text-muted-foreground align-top">
{{ formatDateTime(item.created_at) }}
</td>
<td class="px-4 py-2 text-muted-foreground">
{{ formatDateTime(item.updated_at) }} {{ formatDateTime(item.updated_at) }}
</td> </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> </tr>
</tbody> </tbody>
</table> </table>
@@ -154,35 +209,58 @@
</Pagination> </Pagination>
</div> </div>
</template> </template>
<ScheduleFormDialog
v-model:open="dialogOpen"
:bot-id="botId"
:mode="dialogMode"
:schedule="dialogSchedule"
:timezone="botTimezone"
@saved="handleSaved"
/>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { Calendar } from 'lucide-vue-next' import { Calendar, Pencil, Plus, Trash2 } from 'lucide-vue-next'
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted, reactive } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { import {
Button, Badge, Spinner, Button, Badge, Spinner, Switch,
Pagination, PaginationContent, PaginationEllipsis, Pagination, PaginationContent, PaginationEllipsis,
PaginationFirst, PaginationItem, PaginationLast, PaginationFirst, PaginationItem, PaginationLast,
PaginationNext, PaginationPrevious, PaginationNext, PaginationPrevious,
} from '@memohai/ui' } from '@memohai/ui'
import { getBotsByBotIdSchedule } from '@memohai/sdk' import {
deleteBotsByBotIdScheduleById,
getBotsByBotIdSchedule,
getBotsByBotIdSettings,
putBotsByBotIdScheduleById,
} from '@memohai/sdk'
import type { ScheduleSchedule } from '@memohai/sdk' import type { ScheduleSchedule } from '@memohai/sdk'
import ConfirmPopover from '@/components/confirm-popover/index.vue'
import { resolveApiErrorMessage } from '@/utils/api-error' import { resolveApiErrorMessage } from '@/utils/api-error'
import { formatDateTime } from '@/utils/date-time' import { formatDateTime } from '@/utils/date-time'
import { describeCron } from '@/utils/cron-pattern'
import ScheduleFormDialog from './schedule-form-dialog.vue'
const props = defineProps<{ const props = defineProps<{
botId: string botId: string
}>() }>()
const { t } = useI18n() const { t, locale } = useI18n()
const isLoading = ref(false) const isLoading = ref(false)
const schedules = ref<ScheduleSchedule[]>([]) const schedules = ref<ScheduleSchedule[]>([])
const currentPage = ref(1) const currentPage = ref(1)
const PAGE_SIZE = 10 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)) const totalPages = computed(() => Math.ceil(schedules.value.length / PAGE_SIZE))
@@ -199,6 +277,21 @@ const paginationSummary = computed(() => {
return `${start}-${end} / ${total}` 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() { async function fetchSchedules() {
if (!props.botId) return if (!props.botId) return
isLoading.value = true 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() { async function handleRefresh() {
currentPage.value = 1 currentPage.value = 1
await fetchSchedules() 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(() => { onMounted(() => {
fetchSchedules() fetchSchedules()
fetchBotSettings()
}) })
</script> </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>
+218
View File
@@ -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)
})
})
+311
View File
@@ -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
+26
View File
@@ -134,6 +134,12 @@ importers:
animate.css: animate.css:
specifier: ^4.1.1 specifier: ^4.1.1
version: 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: dotenv:
specifier: ^17.2.3 specifier: ^17.2.3
version: 17.2.3 version: 17.2.3
@@ -2788,6 +2794,14 @@ packages:
cose-base@2.2.0: cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==} 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: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3703,6 +3717,10 @@ packages:
peerDependencies: peerDependencies:
vue: '>=3.0.1' vue: '>=3.0.1'
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -7183,6 +7201,12 @@ snapshots:
dependencies: dependencies:
layout-base: 2.0.1 layout-base: 2.0.1
cron-parser@5.5.0:
dependencies:
luxon: 3.7.2
cronstrue@3.14.0: {}
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -8197,6 +8221,8 @@ snapshots:
dependencies: dependencies:
vue: 3.5.26(typescript@5.9.3) vue: 3.5.26(typescript@5.9.3)
luxon@3.7.2: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
globals: true, globals: true,
include: [ include: [
'packages/**/*.test.ts', 'packages/**/*.test.ts',
'apps/**/*.test.ts',
], ],
env: process.env, env: process.env,
testTimeout: Infinity, testTimeout: Infinity,