- Vite + Vue3 + Pinia + TypeScript + UnoCSS + TDesign 技术栈 - 左侧固定导航 + 暗黑/浅色主题 + PC/移动端响应式布局 - 14 个工具:图片压缩/裁剪、二维码、JSON、时间戳、Base64、 JWT、Cron、进制转换、哈希、身份证、地址解析、URL 解析、OCR - 本地历史记录(localStorage)与通用复制/下载/错误提示组件 - 所有文件处理均在浏览器本地完成,不上传服务器
286 lines
8.4 KiB
Vue
286 lines
8.4 KiB
Vue
<template>
|
||
<div class="page-wrap">
|
||
<h2 class="page-title">Cron 表达式工具</h2>
|
||
<p class="page-desc">解析 Cron 表达式的下次执行时间,或通过可视化表单生成 Cron 表达式。</p>
|
||
|
||
<t-tabs v-model="tab">
|
||
<!-- ==================== 解析 ==================== -->
|
||
<t-tab-panel value="parse" label="解析表达式">
|
||
<t-card class="tool-section" title="输入表达式">
|
||
<t-input v-model="expr" placeholder="例如 0 9 * * 1-5(每工作日 9 点执行)" clearable />
|
||
<div class="form-actions">
|
||
<t-button theme="primary" @click="parse">解析</t-button>
|
||
<t-button variant="outline" @click="clearParse">清空</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="error" @close="error = ''" />
|
||
|
||
<t-card v-if="nextTimes.length" class="tool-section" title="后续执行时间(北京时间)">
|
||
<div class="field-table" v-if="fields.length">
|
||
<div v-for="(f, i) in fields" :key="i" class="field-row">
|
||
<span class="field-label">{{ f.label }}</span>
|
||
<span class="field-value">{{ f.value }}</span>
|
||
</div>
|
||
</div>
|
||
<ol class="time-list">
|
||
<li v-for="(t, i) in nextTimes" :key="i">{{ t }}</li>
|
||
</ol>
|
||
<div class="form-actions">
|
||
<CopyButton :text="nextTimes.join('\n')" label="复制执行时间" />
|
||
</div>
|
||
</t-card>
|
||
</t-tab-panel>
|
||
|
||
<!-- ==================== 可视化生成 ==================== -->
|
||
<t-tab-panel value="generate" label="可视化生成">
|
||
<t-card class="tool-section" title="快捷预设">
|
||
<div class="preset-list">
|
||
<t-button v-for="p in presets" :key="p.label" variant="outline" size="small" @click="applyPreset(p.expr)">
|
||
{{ p.label }}
|
||
</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<t-card class="tool-section" title="字段配置">
|
||
<div class="form-row" style="margin-bottom: 10px">
|
||
<t-checkbox v-model="withSeconds">包含秒字段(6 段表达式)</t-checkbox>
|
||
</div>
|
||
<div v-for="f in genFields" :key="f.key" class="form-row gen-row">
|
||
<span class="form-label">{{ f.label }}</span>
|
||
<t-select v-model="genValues[f.key]" :options="f.options" class="form-select" />
|
||
</div>
|
||
<div class="form-actions">
|
||
<t-button theme="primary" @click="buildExpr">生成表达式</t-button>
|
||
</div>
|
||
</t-card>
|
||
|
||
<ErrorAlert :message="genError" @close="genError = ''" />
|
||
|
||
<t-card v-if="genResult" class="tool-section" title="生成的表达式">
|
||
<div class="code-block">{{ genResult }}</div>
|
||
<div class="form-actions">
|
||
<CopyButton :text="genResult" label="复制表达式" />
|
||
<t-button variant="outline" size="small" @click="useGenerated">到「解析」中查看执行时间</t-button>
|
||
</div>
|
||
</t-card>
|
||
</t-tab-panel>
|
||
</t-tabs>
|
||
|
||
<HistoryPanel
|
||
:items="history.items"
|
||
@select="onHistorySelect"
|
||
@remove="history.remove"
|
||
@clear="history.clear"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { computed, reactive, ref } from 'vue'
|
||
import { parseExpression } from 'cron-parser'
|
||
import ErrorAlert from '@/components/common/ErrorAlert.vue'
|
||
import CopyButton from '@/components/common/CopyButton.vue'
|
||
import HistoryPanel from '@/components/common/HistoryPanel.vue'
|
||
import { useHistory } from '@/composables/useHistory'
|
||
import { formatBeijing } from '@/utils'
|
||
|
||
const tab = ref('parse')
|
||
const history = useHistory('cron')
|
||
|
||
/* ---------- 解析 ---------- */
|
||
const expr = ref('')
|
||
const error = ref('')
|
||
const nextTimes = ref<string[]>([])
|
||
const fields = ref<{ label: string; value: string }[]>([])
|
||
|
||
/** 字段标签:6 段时首段为秒,5 段时依次为分/时/日/月/周 */
|
||
function fieldLabels(segCount: number): string[] {
|
||
const base = ['分钟', '小时', '日', '月', '星期']
|
||
return segCount === 6 ? ['秒', ...base] : base
|
||
}
|
||
|
||
function parse() {
|
||
error.value = ''
|
||
nextTimes.value = []
|
||
fields.value = []
|
||
const raw = expr.value.trim()
|
||
if (!raw) {
|
||
error.value = '请输入 Cron 表达式'
|
||
return
|
||
}
|
||
try {
|
||
// cron-parser:解析失败会抛异常
|
||
const interval = parseExpression(raw, { currentDate: new Date() })
|
||
const segs = raw.split(/\s+/)
|
||
fields.value = fieldLabels(segs.length).map((label, i) => ({ label, value: segs[i] ?? '' }))
|
||
const times: string[] = []
|
||
for (let i = 0; i < 6; i++) {
|
||
times.push(formatBeijing(interval.next().toDate(), true))
|
||
}
|
||
nextTimes.value = times
|
||
history.add(raw)
|
||
} catch (e) {
|
||
error.value = `表达式不合法:${e instanceof Error ? e.message : String(e)}`
|
||
}
|
||
}
|
||
|
||
function clearParse() {
|
||
expr.value = ''
|
||
error.value = ''
|
||
nextTimes.value = []
|
||
fields.value = []
|
||
}
|
||
|
||
/* ---------- 可视化生成 ---------- */
|
||
interface GenField {
|
||
key: 'second' | 'minute' | 'hour' | 'dayOfMonth' | 'month' | 'dayOfWeek'
|
||
label: string
|
||
range: [number, number]
|
||
}
|
||
|
||
const FIELD_DEFS: GenField[] = [
|
||
{ key: 'second', label: '秒', range: [0, 59] },
|
||
{ key: 'minute', label: '分钟', range: [0, 59] },
|
||
{ key: 'hour', label: '小时', range: [0, 23] },
|
||
{ key: 'dayOfMonth', label: '日', range: [1, 31] },
|
||
{ key: 'month', label: '月', range: [1, 12] },
|
||
{ key: 'dayOfWeek', label: '星期', range: [0, 6] },
|
||
]
|
||
|
||
const withSeconds = ref(false)
|
||
const genValues = reactive<Record<GenField['key'], string>>({
|
||
second: '*',
|
||
minute: '*',
|
||
hour: '*',
|
||
dayOfMonth: '*',
|
||
month: '*',
|
||
dayOfWeek: '*',
|
||
})
|
||
const genError = ref('')
|
||
const genResult = ref('')
|
||
|
||
/** 每个字段的选项:任意 + 全量取值范围(星期用 0-6 表示周日-周六) */
|
||
const genFields = computed(() =>
|
||
FIELD_DEFS.filter((f) => f.key !== 'second' || withSeconds.value).map((f) => ({
|
||
...f,
|
||
options: [
|
||
{ label: '任意(*)', value: '*' },
|
||
...Array.from({ length: f.range[1] - f.range[0] + 1 }, (_, i) => {
|
||
const v = String(f.range[0] + i)
|
||
return f.key === 'dayOfWeek'
|
||
? { label: `周${['日', '一', '二', '三', '四', '五', '六'][f.range[0] + i]}`, value: v }
|
||
: { label: v, value: v }
|
||
}),
|
||
],
|
||
})),
|
||
)
|
||
|
||
/** 快捷预设(标准 5 段) */
|
||
const presets = [
|
||
{ label: '每分钟', expr: '* * * * *' },
|
||
{ label: '每小时', expr: '0 * * * *' },
|
||
{ label: '每天 0 点', expr: '0 0 * * *' },
|
||
{ label: '每工作日 9 点', expr: '0 9 * * 1-5' },
|
||
{ label: '每月 1 日 0 点', expr: '0 0 1 * *' },
|
||
{ label: '每周一 9 点', expr: '0 9 * * 1' },
|
||
]
|
||
|
||
function applyPreset(e: string) {
|
||
const segs = e.split(' ')
|
||
withSeconds.value = false
|
||
genValues.minute = segs[0]
|
||
genValues.hour = segs[1]
|
||
genValues.dayOfMonth = segs[2]
|
||
genValues.month = segs[3]
|
||
genValues.dayOfWeek = segs[4]
|
||
buildExpr()
|
||
}
|
||
|
||
function buildExpr() {
|
||
genError.value = ''
|
||
genResult.value = ''
|
||
try {
|
||
const keys: GenField['key'][] = withSeconds.value
|
||
? ['second', 'minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']
|
||
: ['minute', 'hour', 'dayOfMonth', 'month', 'dayOfWeek']
|
||
const segs = keys.map((k) => genValues[k])
|
||
const e = segs.join(' ')
|
||
// 用 cron-parser 校验生成的表达式合法性
|
||
parseExpression(e)
|
||
genResult.value = e
|
||
history.add(e)
|
||
} catch {
|
||
genError.value = '生成的表达式不合法,请检查字段配置'
|
||
}
|
||
}
|
||
|
||
function useGenerated() {
|
||
tab.value = 'parse'
|
||
expr.value = genResult.value
|
||
parse()
|
||
}
|
||
|
||
function onHistorySelect(text: string) {
|
||
tab.value = 'parse'
|
||
expr.value = text
|
||
parse()
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.form-actions {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin-top: 16px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.form-label {
|
||
width: 56px;
|
||
flex-shrink: 0;
|
||
font-size: 14px;
|
||
color: var(--td-text-color-secondary);
|
||
}
|
||
.form-select {
|
||
width: 200px;
|
||
}
|
||
.gen-row {
|
||
margin-bottom: 10px;
|
||
}
|
||
.preset-list {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.field-table {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.field-row {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 6px 12px;
|
||
border-radius: 6px;
|
||
background: var(--td-bg-color-secondarycontainer);
|
||
}
|
||
.field-label {
|
||
font-size: 12px;
|
||
color: var(--td-text-color-placeholder);
|
||
}
|
||
.field-value {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--td-text-color-primary);
|
||
}
|
||
.time-list {
|
||
margin: 0;
|
||
padding-left: 20px;
|
||
font-size: 14px;
|
||
line-height: 2;
|
||
color: var(--td-text-color-primary);
|
||
}
|
||
</style>
|