作为后端、运维同学的日常刚需,Cron 表达式的编写总是让人头疼 ------*和?的区别、周与日的冲突、复杂周期的拼接,每次写都得翻文档。最近我用Vue3 + naive-cron(一款开箱即用的 Cron 可视化组件),花了不到 半小时撸了个小工具:既能通过可视化面板 "点选" 生成表达式,也能把晦涩的 Cron 串反解析成人类能懂的自然语言,甚至能预览最近执行时间。分享下从构思到落地的全过程~
一、技术栈选择
- 框架:Vue3
- UI 组件:Naive UI(和 naive-cron 天然适配,风格统一)
- 核心依赖:
naive-cron(Cron 可视化生成 / 解析的核心组件) - 构建工具:Vite(开发体验拉满)
二、核心功能实现(代码片段)
核心是<naive-cron>组件,绑定v-model就能双向同步 Cron 表达式,再配合解析结果展示:
页面结构:可视化编辑器 + 结果展示代码
<div class="cron-content">
<!-- 表达式输入区域 -->
<div class="expression-section">
<n-input-group>
<n-input
v-model:value="cronExpression"
placeholder="请输入或生成Cron表达式"
style="font-family: monospace; font-size: 15px;"
@keyup.enter="parseCron"
/>
<n-button type="primary" @click="parseCron">
<template #icon><n-icon><ParseIcon /></n-icon></template>
解析
</n-button>
<n-button @click="addToHistory" :disabled="!cronExpression.trim()">
<template #icon><n-icon><SaveIcon /></n-icon></template>
保存
</n-button>
<n-button tertiary @click="copyExpression">
<template #icon><n-icon><CopyIcon /></n-icon></template>
复制
</n-button>
</n-input-group>
</div>
<n-grid :cols="24" :x-gap="20" style="margin-top: 16px;">
<!-- 左侧:Cron编辑器 -->
<n-gi :span="16">
<div class="editor-wrapper">
<div class="section-title">可视化配置器</div>
<!-- 选项卡 -->
<n-tabs v-model:value="activeTab" type="card" class="cron-tabs">
<n-tab-pane name="second" tab="秒">
<div class="field-panel">
<n-radio-group v-model:value="fields.second.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="every">每秒执行 <span class="hint">*</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config">
<span>周期:从</span>
<n-input-number v-model:value="fields.second.range.from" :min="0" :max="59" size="small" style="width: 70px;" />
<span>到</span>
<n-input-number v-model:value="fields.second.range.to" :min="0" :max="59" size="small" style="width: 70px;" />
<span>秒</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="interval">
<div class="inline-config">
<span>从第</span>
<n-input-number v-model:value="fields.second.interval.start" :min="0" :max="59" size="small" style="width: 70px;" />
<span>秒开始,每隔</span>
<n-input-number v-model:value="fields.second.interval.step" :min="1" :max="59" size="small" style="width: 70px;" />
<span>秒执行</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="specific">指定秒数</n-radio>
</div>
</n-radio-group>
<div v-if="fields.second.type === 'specific'" class="specific-checkboxes">
<n-checkbox-group v-model:value="fields.second.specific">
<n-space :size="[12, 6]" style="flex-wrap: wrap;">
<n-checkbox v-for="i in 60" :key="i - 1" :value="i - 1" :label="String(i - 1)" />
</n-space>
</n-checkbox-group>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="minute" tab="分">
<div class="field-panel">
<n-radio-group v-model:value="fields.minute.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="every">每分钟执行 <span class="hint">*</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config">
<span>周期:从</span>
<n-input-number v-model:value="fields.minute.range.from" :min="0" :max="59" size="small" style="width: 70px;" />
<span>到</span>
<n-input-number v-model:value="fields.minute.range.to" :min="0" :max="59" size="small" style="width: 70px;" />
<span>分</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="interval">
<div class="inline-config">
<span>从第</span>
<n-input-number v-model:value="fields.minute.interval.start" :min="0" :max="59" size="small" style="width: 70px;" />
<span>分开始,每隔</span>
<n-input-number v-model:value="fields.minute.interval.step" :min="1" :max="59" size="small" style="width: 70px;" />
<span>分钟执行</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="specific">指定分钟</n-radio>
</div>
</n-radio-group>
<div v-if="fields.minute.type === 'specific'" class="specific-checkboxes">
<n-checkbox-group v-model:value="fields.minute.specific">
<n-space :size="[12, 6]" style="flex-wrap: wrap;">
<n-checkbox v-for="i in 60" :key="i - 1" :value="i - 1" :label="String(i - 1)" />
</n-space>
</n-checkbox-group>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="hour" tab="时">
<div class="field-panel">
<n-radio-group v-model:value="fields.hour.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="every">每小时执行 <span class="hint">*</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config">
<span>周期:从</span>
<n-input-number v-model:value="fields.hour.range.from" :min="0" :max="23" size="small" style="width: 70px;" />
<span>到</span>
<n-input-number v-model:value="fields.hour.range.to" :min="0" :max="23" size="small" style="width: 70px;" />
<span>点</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="interval">
<div class="inline-config">
<span>从</span>
<n-input-number v-model:value="fields.hour.interval.start" :min="0" :max="23" size="small" style="width: 70px;" />
<span>点开始,每隔</span>
<n-input-number v-model:value="fields.hour.interval.step" :min="1" :max="23" size="small" style="width: 70px;" />
<span>小时执行</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="specific">指定小时</n-radio>
</div>
</n-radio-group>
<div v-if="fields.hour.type === 'specific'" class="specific-checkboxes">
<n-checkbox-group v-model:value="fields.hour.specific">
<n-space :size="[12, 6]" style="flex-wrap: wrap;">
<n-checkbox v-for="i in 24" :key="i - 1" :value="i - 1" :label="String(i - 1)" />
</n-space>
</n-checkbox-group>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="day" tab="日">
<div class="field-panel">
<n-radio-group v-model:value="fields.day.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="every">每天执行 <span class="hint">*</span></n-radio>
<n-radio value="ignore">不指定 <span class="hint">?</span></n-radio>
<n-radio value="last">每月最后一天 <span class="hint">L</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config">
<span>周期:从</span>
<n-input-number v-model:value="fields.day.range.from" :min="1" :max="31" size="small" style="width: 70px;" />
<span>到</span>
<n-input-number v-model:value="fields.day.range.to" :min="1" :max="31" size="small" style="width: 70px;" />
<span>号</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="interval">
<div class="inline-config">
<span>从</span>
<n-input-number v-model:value="fields.day.interval.start" :min="1" :max="31" size="small" style="width: 70px;" />
<span>号开始,每隔</span>
<n-input-number v-model:value="fields.day.interval.step" :min="1" :max="31" size="small" style="width: 70px;" />
<span>天执行</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="specific">指定日期</n-radio>
</div>
</n-radio-group>
<div v-if="fields.day.type === 'specific'" class="specific-checkboxes">
<n-checkbox-group v-model:value="fields.day.specific">
<n-space :size="[12, 6]" style="flex-wrap: wrap;">
<n-checkbox v-for="i in 31" :key="i" :value="i" :label="String(i)" />
</n-space>
</n-checkbox-group>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="month" tab="月">
<div class="field-panel">
<n-radio-group v-model:value="fields.month.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="every">每月执行 <span class="hint">*</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config">
<span>周期:从</span>
<n-input-number v-model:value="fields.month.range.from" :min="1" :max="12" size="small" style="width: 70px;" />
<span>到</span>
<n-input-number v-model:value="fields.month.range.to" :min="1" :max="12" size="small" style="width: 70px;" />
<span>月</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="interval">
<div class="inline-config">
<span>从</span>
<n-input-number v-model:value="fields.month.interval.start" :min="1" :max="12" size="small" style="width: 70px;" />
<span>月开始,每隔</span>
<n-input-number v-model:value="fields.month.interval.step" :min="1" :max="12" size="small" style="width: 70px;" />
<span>个月执行</span>
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="specific">指定月份</n-radio>
</div>
</n-radio-group>
<div v-if="fields.month.type === 'specific'" class="specific-checkboxes month-checkboxes">
<n-checkbox-group v-model:value="fields.month.specific">
<n-space>
<n-checkbox v-for="m in monthOptions" :key="m.value" :value="m.value" :label="m.label" />
</n-space>
</n-checkbox-group>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="week" tab="周">
<div class="field-panel">
<n-radio-group v-model:value="fields.week.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="ignore">不指定 <span class="hint">?</span></n-radio>
<n-radio value="every">每天执行 <span class="hint">*</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config" @click.stop>
<span>周期:从</span>
<n-select v-model:value="fields.week.range.from" :options="weekOptions" size="small" style="width: 85px;" />
<span>到</span>
<n-select v-model:value="fields.week.range.to" :options="weekOptions" size="small" style="width: 85px;" />
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="nth">
<div class="inline-config" @click.stop>
<span>第</span>
<n-input-number v-model:value="fields.week.nth.n" :min="1" :max="5" size="small" style="width: 80px;" />
<span>个</span>
<n-select v-model:value="fields.week.nth.day" :options="weekOptions" size="small" style="width: 85px;" />
</div>
</n-radio>
</div>
<div class="radio-row">
<n-radio value="specific">指定星期</n-radio>
</div>
</n-radio-group>
<div v-if="fields.week.type === 'specific'" class="specific-checkboxes week-checkboxes">
<n-checkbox-group v-model:value="fields.week.specific">
<n-space>
<n-checkbox v-for="w in weekOptions" :key="w.value" :value="w.value" :label="w.label" />
</n-space>
</n-checkbox-group>
</div>
</div>
</n-tab-pane>
<n-tab-pane name="year" tab="年">
<div class="field-panel">
<n-radio-group v-model:value="fields.year.type" class="radio-horizontal">
<div class="radio-row">
<n-radio value="empty">不填写 <span class="hint">留空</span></n-radio>
<n-radio value="every">每年执行 <span class="hint">*</span></n-radio>
</div>
<div class="radio-row">
<n-radio value="range">
<div class="inline-config">
<span>周期:从</span>
<n-input-number v-model:value="fields.year.range.from" :min="2020" :max="2099" size="small" style="width: 90px;" />
<span>到</span>
<n-input-number v-model:value="fields.year.range.to" :min="2020" :max="2099" size="small" style="width: 90px;" />
<span>年</span>
</div>
</n-radio>
</div>
</n-radio-group>
</div>
</n-tab-pane>
</n-tabs>
<!-- 表达式结果展示 -->
<div class="result-table">
<n-table :bordered="false" :single-line="false" size="small">
<thead>
<tr>
<th>秒</th><th>分</th><th>时</th><th>日</th><th>月</th><th>周</th><th>年</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>{{ computedFields.second }}</code></td>
<td><code>{{ computedFields.minute }}</code></td>
<td><code>{{ computedFields.hour }}</code></td>
<td><code>{{ computedFields.day }}</code></td>
<td><code>{{ computedFields.month }}</code></td>
<td><code>{{ computedFields.week }}</code></td>
<td><code>{{ computedFields.year || '-' }}</code></td>
</tr>
</tbody>
</n-table>
</div>
</div>
<!-- 表达式说明 -->
<div class="description-section" v-if="cronDescription">
<div class="section-title">表达式说明</div>
<n-alert type="info" :show-icon="false" style="border-radius: 8px;">
{{ cronDescription }}
</n-alert>
</div>
<!-- 执行时间预览 -->
<div class="preview-section">
<div class="section-title">执行时间预览(近{{ executionTimes.length }}次)</div>
<n-spin :show="parsing">
<div class="execution-times">
<n-empty v-if="executionTimes.length === 0" description="请输入Cron表达式并解析" size="small" />
<div v-else class="time-list">
<div v-for="(time, index) in executionTimes" :key="index" class="time-item">
<n-icon color="#18a058" size="14"><CheckIcon /></n-icon>
<span>{{ time }}</span>
</div>
</div>
</div>
</n-spin>
</div>
</n-gi>
<!-- 右侧:示例和历史 -->
<n-gi :span="8">
<div class="side-section" style="">
<div class="section-title">
<span>常用示例</span>
<n-button text size="small" type="info" @click="showHelpModal = true">
表达式详解
</n-button>
</div>
<div class="example-list">
<div
v-for="example in cronExamples"
:key="example.expression"
class="example-item"
@click="applyExample(example)"
>
<code class="example-code">{{ example.expression }}</code>
<span class="example-desc">{{ example.description }}</span>
</div>
</div>
</div>
<div class="side-section" style="margin-top: 16px;">
<div class="section-title">
<span>历史记录</span>
<n-button text size="small" type="error" @click="clearHistory" v-if="historyList.length > 0">
清空
</n-button>
</div>
<div class="history-list">
<n-empty v-if="historyList.length === 0" description="暂无历史记录" size="small" />
<div
v-for="(item, index) in historyList"
:key="index"
class="history-item"
>
<div class="history-content" @click="applyHistory(item)">
<code class="history-code">{{ item.expression }}</code>
<span class="history-time">{{ item.time }}</span>
</div>
<n-button text size="tiny" type="error" @click.stop="removeHistory(index)">
<n-icon><DeleteIcon /></n-icon>
</n-button>
</div>
</div>
</div>
</n-gi>
</n-grid>
</div>
</n-card>
三、效果展示
