feat: 新增定时任务功能

This commit is contained in:
duanshuwen
2026-04-11 23:17:54 +08:00
parent 37ed157e4a
commit 67808a459e
25 changed files with 4149 additions and 22 deletions

View File

@@ -0,0 +1,168 @@
<template>
<div
class="group flex flex-col p-5 rounded-2xl bg-transparent border border-transparent hover:bg-black/[0.03] transition-all relative overflow-hidden cursor-pointer"
@click="handleEdit"
>
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-4">
<div class="h-[46px] w-[46px] shrink-0 flex items-center justify-center text-[#171717] bg-black/5 border border-black/5 rounded-full shadow-sm group-hover:scale-105 transition-transform">
<RiTimeLine :class="['h-5 w-5', job.enabled ? 'text-[#171717]' : 'text-[#99A0AE]']" />
</div>
<div class="flex flex-col min-w-0">
<div class="flex items-center gap-2 mb-1">
<h3 class="text-[16px] font-semibold text-[#171717] truncate">{{ job.name }}</h3>
<div
:class="[
'w-2 h-2 rounded-full shrink-0',
job.enabled ? 'bg-green-500' : 'bg-[#99A0AE]'
]"
:title="job.enabled ? t('cron.stats.active') : t('cron.stats.paused')"
/>
</div>
<p class="text-[13px] text-[#525866] flex items-center gap-1.5">
<RiTimerLine class="h-3.5 w-3.5" />
{{ parseCronSchedule(job.schedule, t) }}
</p>
</div>
</div>
<div class="flex items-center gap-2" @click.stop>
<el-switch
:model-value="job.enabled"
@update:model-value="handleToggle"
/>
</div>
</div>
<div class="flex-1 flex flex-col justify-end mt-2 pl-[62px]">
<div class="flex items-start gap-2 mb-3">
<RiMessage3Line class="h-3.5 w-3.5 mt-0.5 text-[#99A0AE] shrink-0" />
<p class="text-[13.5px] text-[#525866] line-clamp-2 leading-[1.5]">
{{ job.message }}
</p>
</div>
<!-- Metadata -->
<div class="flex flex-wrap items-center gap-x-4 gap-y-2 text-[12px] text-[#99A0AE] font-medium mb-3">
<span v-if="job.lastRun" class="flex items-center gap-1.5">
<RiHistoryLine class="h-3.5 w-3.5" />
{{ t('cron.card.last') }}: {{ formatRelativeTime(job.lastRun.time) }}
<RiCheckLine v-if="job.lastRun.success" class="h-3.5 w-3.5 text-green-500" />
<RiCloseLine v-else class="h-3.5 w-3.5 text-red-500" />
</span>
<span v-if="job.nextRun && job.enabled" class="flex items-center gap-1.5">
<RiCalendarLine class="h-3.5 w-3.5" />
{{ t('cron.card.next') }}: {{ new Date(job.nextRun).toLocaleString() }}
</span>
</div>
<!-- Last Run Error -->
<div v-if="job.lastRun && !job.lastRun.success && job.lastRun.error" class="flex items-start gap-2 p-2.5 mb-3 rounded-xl bg-red-500/10 border border-red-500/20 text-[13px] text-red-600">
<RiErrorWarningLine class="h-4 w-4 mt-0.5 shrink-0" />
<span class="line-clamp-2">{{ job.lastRun.error }}</span>
</div>
<!-- Actions -->
<div class="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity mt-auto">
<el-button
text
size="small"
class="!h-8 !px-3 !text-[13px] !font-medium !rounded-lg"
@click.stop="handleTrigger"
:loading="triggering"
>
<template #icon>
<RiPlayLine class="h-3.5 w-3.5 mr-1.5" />
</template>
{{ t('cron.card.runNow') }}
</el-button>
<el-button
text
type="danger"
size="small"
class="!h-8 !px-3 !text-[13px] !font-medium !rounded-lg"
@click.stop="handleDelete"
>
<template #icon>
<RiDeleteBinLine class="h-3.5 w-3.5 mr-1.5" />
</template>
{{ t('common.delete', 'Delete') }}
</el-button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
RiTimeLine,
RiTimerLine,
RiMessage3Line,
RiHistoryLine,
RiCalendarLine,
RiCheckLine,
RiCloseLine,
RiErrorWarningLine,
RiPlayLine,
RiDeleteBinLine,
} from '@remixicon/vue';
import type { CronJob } from '@lib/cron-types';
import { parseCronSchedule } from '../utils';
const { t } = useI18n();
const props = defineProps<{
job: CronJob;
}>();
const emit = defineEmits<{
(e: 'toggle', enabled: boolean): void;
(e: 'edit'): void;
(e: 'delete'): void;
(e: 'trigger'): Promise<void>;
}>();
const triggering = ref(false);
function handleEdit() {
emit('edit');
}
function handleToggle(enabled: boolean) {
emit('toggle', enabled);
}
function handleDelete() {
emit('delete');
}
async function handleTrigger() {
triggering.value = true;
try {
await emit('trigger');
} finally {
triggering.value = false;
}
}
function formatRelativeTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffSec = Math.round(diffMs / 1000);
const diffMin = Math.round(diffSec / 60);
const diffHour = Math.round(diffMin / 60);
const diffDay = Math.round(diffHour / 24);
if (diffSec < 10) return t('common.timeAgo.justNow', 'just now');
if (diffMin < 1) return t('common.timeAgo.minutes', { count: diffMin }, `${diffMin}分钟前`);
if (diffHour < 1) return t('common.timeAgo.hours', { count: diffHour }, `${diffHour}小时前`);
if (diffDay < 1) return t('common.timeAgo.days', { count: diffDay }, `${diffDay}天前`);
if (diffDay < 30) return t('common.timeAgo.days', { count: diffDay }, `${diffDay}天前`);
return new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' }).format(date);
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<!-- Total -->
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-transparent flex flex-col justify-between min-h-[130px] relative overflow-hidden hover:bg-[#F4F3EB] transition-colors">
<div class="flex items-center justify-between">
<div class="h-11 w-11 rounded-full bg-[#E8E6DE] flex items-center justify-center">
<RiTimeLine class="h-5 w-5 text-[#7A7668]" />
</div>
</div>
<div class="mt-4 flex items-baseline gap-3">
<p class="text-[40px] leading-none font-serif text-[#171717]">{{ total }}</p>
<p class="text-[14px] font-medium text-[#525866]">{{ t('cron.stats.total') }}</p>
</div>
</div>
<!-- Active -->
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-transparent flex flex-col justify-between min-h-[130px] relative overflow-hidden hover:bg-[#F4F3EB] transition-colors">
<div class="flex items-center justify-between">
<div class="h-11 w-11 rounded-full bg-green-100 flex items-center justify-center">
<RiPlayLine class="h-5 w-5 text-green-600 ml-0.5" />
</div>
</div>
<div class="mt-4 flex items-baseline gap-3">
<p class="text-[40px] leading-none font-serif text-[#171717]">{{ active }}</p>
<p class="text-[14px] font-medium text-[#525866]">{{ t('cron.stats.active') }}</p>
</div>
</div>
<!-- Paused -->
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-transparent flex flex-col justify-between min-h-[130px] relative overflow-hidden hover:bg-[#F4F3EB] transition-colors">
<div class="flex items-center justify-between">
<div class="h-11 w-11 rounded-full bg-amber-100 flex items-center justify-center">
<RiPauseLine class="h-5 w-5 text-amber-600" />
</div>
</div>
<div class="mt-4 flex items-baseline gap-3">
<p class="text-[40px] leading-none font-serif text-[#171717]">{{ paused }}</p>
<p class="text-[14px] font-medium text-[#525866]">{{ t('cron.stats.paused') }}</p>
</div>
</div>
<!-- Failed -->
<div class="p-5 rounded-[24px] bg-[#F4F3EB]/60 border border-transparent flex flex-col justify-between min-h-[130px] relative overflow-hidden hover:bg-[#F4F3EB] transition-colors">
<div class="flex items-center justify-between">
<div class="h-11 w-11 rounded-full bg-red-100 flex items-center justify-center">
<RiCloseCircleLine class="h-5 w-5 text-red-500" />
</div>
</div>
<div class="mt-4 flex items-baseline gap-3">
<p class="text-[40px] leading-none font-serif text-[#171717]">{{ failed }}</p>
<p class="text-[14px] font-medium text-[#525866]">{{ t('cron.stats.failed') }}</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import {
RiTimeLine,
RiPlayLine,
RiPauseLine,
RiCloseCircleLine,
} from '@remixicon/vue';
const { t } = useI18n();
defineProps<{
total: number;
active: number;
paused: number;
failed: number;
}>();
</script>

View File

@@ -0,0 +1,406 @@
<template>
<el-dialog
v-model="visible"
width="640px"
:show-close="false"
class="custom-cron-dialog"
@closed="handleClosed"
>
<!-- Header -->
<template #header>
<div class="sticky top-0 z-10 bg-[#F4F3EB] flex justify-between items-start">
<div>
<h2 class="text-[24px] font-serif text-[#171717] mb-[8px] font-normal tracking-tight">
{{ job ? t('cron.dialog.editTitle') : t('cron.dialog.createTitle') }}
</h2>
<p class="text-[14px] text-[#99A0AE]">
{{ t('cron.dialog.description') }}
</p>
</div>
<button @click="visible = false" class="text-[#99A0AE] hover:text-[#171717] transition-colors mt-[4px]">
<el-icon class="text-[20px] cursor-pointer"><Close /></el-icon>
</button>
</div>
</template>
<!-- Form -->
<div class="px-[30px] pb-[30px] pt-[30px] space-y-6">
<!-- Name -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('cron.dialog.taskName') }}</label>
<el-input
v-model="form.name"
:placeholder="t('cron.dialog.taskNamePlaceholder')"
class="!h-[44px]"
/>
</div>
<!-- Message -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('cron.dialog.message') }}</label>
<el-input
v-model="form.message"
type="textarea"
:rows="3"
:placeholder="t('cron.dialog.messagePlaceholder')"
resize="none"
/>
</div>
<!-- Schedule -->
<div class="space-y-2.5">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('cron.dialog.schedule') }}</label>
<!-- Presets -->
<div v-if="!useCustom" class="grid grid-cols-2 gap-2">
<el-button
v-for="preset in schedulePresets"
:key="preset.value"
size="default"
class="!justify-start !h-10 !rounded-xl !font-medium !text-[13px] !transition-all schedule-btn"
:class="form.schedule === preset.value ? 'schedule-btn--active' : 'schedule-btn--inactive'"
@click="form.schedule = preset.value"
>
<RiTimerLine class="h-4 w-4 mr-2 opacity-70" />
{{ t('cron.presets.' + preset.key) }}
</el-button>
</div>
<!-- Custom cron input -->
<el-input
v-else
v-model="customSchedule"
:placeholder="t('cron.dialog.cronPlaceholder')"
class="!h-[44px]"
/>
<div class="flex items-center justify-between mt-2">
<p class="text-[12px] text-[#99A0AE] font-medium">
{{ schedulePreview ? `${t('cron.card.next')}: ${schedulePreview}` : t('cron.dialog.cronPlaceholder') }}
</p>
<el-button
link
type="primary"
size="small"
class="!text-[12px] !h-7 !px-2"
@click="toggleCustom"
>
{{ useCustom ? t('cron.dialog.usePresets') : t('cron.dialog.useCustomCron') }}
</el-button>
</div>
</div>
<!-- Delivery (simplified for first phase) -->
<div class="space-y-3">
<div class="space-y-1">
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('cron.dialog.deliveryTitle') }}</label>
<p class="text-[12px] text-[#99A0AE]">{{ t('cron.dialog.deliveryDescription') }}</p>
</div>
<div class="grid grid-cols-2 gap-2">
<el-button
size="default"
class="!justify-start !h-auto !min-h-12 !rounded-xl !px-4 !py-3 !text-left !whitespace-normal delivery-btn"
:class="deliveryMode === 'none' ? 'delivery-btn--active' : 'delivery-btn--inactive'"
@click="deliveryMode = 'none'"
>
<div>
<div class="text-[13px] font-semibold">{{ t('cron.dialog.deliveryModeNone') }}</div>
<div class="text-[11px] opacity-80">{{ t('cron.dialog.deliveryModeNoneDesc') }}</div>
</div>
</el-button>
<el-button
size="default"
class="!justify-start !h-auto !min-h-12 !rounded-xl !px-4 !py-3 !text-left !whitespace-normal delivery-btn"
:class="deliveryMode === 'announce' ? 'delivery-btn--active' : 'delivery-btn--inactive'"
@click="deliveryMode = 'announce'"
>
<div>
<div class="text-[13px] font-semibold">{{ t('cron.dialog.deliveryModeAnnounce') }}</div>
<div class="text-[11px] opacity-80">{{ t('cron.dialog.deliveryModeAnnounceDesc') }}</div>
</div>
</el-button>
</div>
<!-- Delivery placeholder hint for announce mode -->
<div v-if="deliveryMode === 'announce'" class="p-3 rounded-xl bg-amber-50 border border-amber-200 text-[12px] text-amber-700">
{{ t('cron.dialog.noChannels') }}
</div>
</div>
<!-- Enabled -->
<div class="flex items-center justify-between bg-[#E8E6DE]/50 p-4 rounded-2xl border border-black/5">
<div>
<label class="text-[14px] text-[#171717]/80 font-bold">{{ t('cron.dialog.enableImmediately') }}</label>
<p class="text-[13px] text-[#99A0AE] mt-0.5">
{{ t('cron.dialog.enableImmediatelyDesc') }}
</p>
</div>
<el-switch v-model="form.enabled" />
</div>
<!-- Actions -->
<div class="flex justify-end gap-3 pt-4">
<el-button
@click="visible = false"
class="!rounded-full !px-6 !h-[42px] !text-[13px] !font-semibold cancel-btn"
>
{{ t('common.dialog.cancel') }}
</el-button>
<el-button
type="primary"
@click="handleSubmit"
:loading="saving"
class="!rounded-full !px-6 !h-[42px] !text-[13px] !font-semibold"
>
<template #icon>
<RiCheckLine class="h-4 w-4 mr-2" />
</template>
{{ saving ? t('common.saving', 'Saving...') : (job ? t('cron.dialog.saveChanges') : t('cron.dialog.createTitle')) }}
</el-button>
</div>
</div>
</el-dialog>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Close } from '@element-plus/icons-vue';
import {
RiTimerLine,
RiCheckLine,
} from '@remixicon/vue';
import { ElMessage } from 'element-plus';
import type { CronJob, CronJobCreateInput } from '@lib/cron-types';
import { schedulePresets, estimateNextRun } from '../utils';
const { t } = useI18n();
const props = defineProps<{
modelValue: boolean;
job?: CronJob;
}>();
const emit = defineEmits<{
(e: 'update:modelValue', value: boolean): void;
(e: 'closed'): void;
(e: 'save', payload: CronJobCreateInput): void;
}>();
const visible = computed({
get: () => props.modelValue,
set: (value) => emit('update:modelValue', value),
});
const saving = ref(false);
const useCustom = ref(false);
const customSchedule = ref('');
const deliveryMode = ref<'none' | 'announce'>('none');
const initialSchedule = (() => {
const s = props.job?.schedule;
if (!s) return '0 9 * * *';
if (typeof s === 'string') return s;
if (typeof s === 'object' && 'expr' in s && typeof (s as { expr: string }).expr === 'string') {
return (s as { expr: string }).expr;
}
return '0 9 * * *';
})();
const form = ref({
name: props.job?.name || '',
message: props.job?.message || '',
schedule: initialSchedule,
enabled: props.job?.enabled ?? true,
});
watch(() => props.job, (job) => {
if (job) {
const s = job.schedule;
let scheduleStr = '0 9 * * *';
if (typeof s === 'string') scheduleStr = s;
else if (typeof s === 'object' && 'expr' in s && typeof (s as { expr: string }).expr === 'string') {
scheduleStr = (s as { expr: string }).expr;
}
form.value = {
name: job.name || '',
message: job.message || '',
schedule: scheduleStr,
enabled: job.enabled ?? true,
};
deliveryMode.value = job.delivery?.mode === 'announce' ? 'announce' : 'none';
const isPreset = schedulePresets.some((p) => p.value === scheduleStr);
useCustom.value = !isPreset;
customSchedule.value = isPreset ? '' : scheduleStr;
} else {
form.value = {
name: '',
message: '',
schedule: '0 9 * * *',
enabled: true,
};
deliveryMode.value = 'none';
useCustom.value = false;
customSchedule.value = '';
}
}, { immediate: true });
const schedulePreview = computed(() => {
const expr = useCustom.value ? customSchedule.value : form.value.schedule;
return estimateNextRun(expr);
});
function toggleCustom() {
useCustom.value = !useCustom.value;
if (useCustom.value) {
const preset = schedulePresets.find((p) => p.value === form.value.schedule);
customSchedule.value = preset ? '' : form.value.schedule;
} else {
const preset = schedulePresets.find((p) => p.value === customSchedule.value);
if (preset) {
form.value.schedule = preset.value;
} else {
form.value.schedule = '0 9 * * *';
}
}
}
function handleSubmit() {
if (!form.value.name.trim()) {
ElMessage.error(t('cron.toast.nameRequired'));
return;
}
if (!form.value.message.trim()) {
ElMessage.error(t('cron.toast.messageRequired'));
return;
}
const finalSchedule = useCustom.value ? customSchedule.value : form.value.schedule;
if (!finalSchedule.trim()) {
ElMessage.error(t('cron.toast.scheduleRequired'));
return;
}
saving.value = true;
try {
const payload: CronJobCreateInput = {
name: form.value.name.trim(),
message: form.value.message.trim(),
schedule: finalSchedule,
enabled: form.value.enabled,
delivery: deliveryMode.value === 'announce'
? { mode: 'announce', channel: '', to: '' }
: { mode: 'none' },
};
emit('save', payload);
visible.value = false;
} finally {
saving.value = false;
}
}
function handleClosed() {
emit('closed');
}
</script>
<style>
.custom-cron-dialog {
background-color: #F4F3EB !important;
border-radius: 20px !important;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15) !important;
}
.custom-cron-dialog .el-dialog__body {
padding: 0 !important;
max-height: calc(100vh - 400px);
overflow-y: auto;
}
/* Simple scrollbar for the dialog body */
.custom-cron-dialog .el-dialog__body::-webkit-scrollbar {
width: 6px;
}
.custom-cron-dialog .el-dialog__body::-webkit-scrollbar-thumb {
background-color: #D1CFC7;
border-radius: 3px;
}
/* Input / Textarea styling to match the cream UI */
.custom-cron-dialog .el-input__wrapper,
.custom-cron-dialog .el-textarea__inner {
background-color: #EDECE4 !important;
border-radius: 12px !important;
box-shadow: none !important;
border: 1px solid transparent !important;
color: #171717 !important;
}
.custom-cron-dialog .el-input__wrapper.is-focus,
.custom-cron-dialog .el-textarea__inner:focus {
border-color: #3B6DE8 !important;
}
.custom-cron-dialog .el-input__inner {
color: #171717 !important;
}
.custom-cron-dialog .el-input__inner::placeholder,
.custom-cron-dialog .el-textarea__inner::placeholder {
color: #99A0AE !important;
}
/* Schedule preset buttons */
.schedule-btn {
border: none !important;
margin-left: 0 !important;
}
.schedule-btn--inactive {
background-color: #EDECE4 !important;
color: #4B4B4B !important;
}
.schedule-btn--inactive:hover {
background-color: #E5E4DC !important;
color: #171717 !important;
}
.schedule-btn--active {
background-color: #3B6DE8 !important;
color: #ffffff !important;
}
/* Delivery buttons */
.delivery-btn {
border: none !important;
}
.delivery-btn--inactive {
background-color: #EDECE4 !important;
color: #4B4B4B !important;
}
.delivery-btn--inactive:hover {
background-color: #E5E4DC !important;
color: #171717 !important;
}
.delivery-btn--active {
background-color: #3B6DE8 !important;
color: #ffffff !important;
}
.delivery-btn--active .opacity-80 {
opacity: 0.9 !important;
color: rgba(255, 255, 255, 0.85) !important;
}
/* Toggle switch override */
.custom-cron-dialog .el-switch.is-checked .el-switch__core {
background-color: #3B6DE8 !important;
border-color: #3B6DE8 !important;
}
/* Cancel button */
.cancel-btn {
background-color: #EDECE4 !important;
border-color: transparent !important;
color: #4B4B4B !important;
}
.cancel-btn:hover {
background-color: #E5E4DC !important;
color: #171717 !important;
}
</style>

206
src/pages/cron/index.vue Normal file
View File

@@ -0,0 +1,206 @@
<template>
<layout>
<div class="bg-white box-border w-full h-full flex rounded-[16px] overflow-hidden">
<div class="w-full flex flex-col h-full p-10 pt-12">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-start justify-between mb-6 shrink-0 gap-4">
<div>
<h1
class="text-5xl md:text-6xl font-serif text-[#171717] mb-3 font-normal tracking-tight"
style="font-family: Georgia, Cambria, 'Times New Roman', Times, serif"
>
{{ t('cron.title') }}
</h1>
<p class="text-[17px] text-[#171717]/70 font-medium">
{{ t('cron.subtitle') }}
</p>
</div>
<div class="flex items-center gap-3 md:mt-2">
<button
@click="store.fetchJobs()"
class="hover:bg-black/5 transition-colors shrink-0 text-[13px] font-medium px-4 h-9 rounded-full border border-black/10 flex items-center justify-center text-[#171717]/80 hover:text-[#171717]"
>
<RiRefreshLine :class="['h-4 w-4 mr-2', store.loading && 'animate-spin']" />
{{ t('cron.refresh') }}
</button>
<button
@click="openCreateDialog"
class="shrink-0 text-[13px] font-medium px-4 h-9 rounded-full bg-[#2B7FFF] hover:bg-[#2B7FFF]/90 text-white flex items-center justify-center shadow-none"
>
<RiAddLine class="h-4 w-4 mr-2" />
{{ t('cron.newTask') }}
</button>
</div>
</div>
<!-- Content Area -->
<div class="flex-1 overflow-y-auto pr-2 pb-10 min-h-0 -mr-2">
<!-- Error Display -->
<div
v-if="store.error"
class="mb-4 p-4 rounded-xl border border-red-500/50 bg-red-500/10 text-red-600 text-sm font-medium flex items-center gap-2"
>
<RiErrorWarningLine class="h-5 w-5 shrink-0" />
<span>{{ store.error }}</span>
</div>
<!-- Loading -->
<div
v-if="store.loading && store.safeJobs.length === 0"
class="flex flex-col items-center justify-center h-full text-[#525866]"
>
<RiRefreshLine class="h-10 w-10 animate-spin mb-4" />
<p>{{ t('common.loading', 'Loading...') }}</p>
</div>
<template v-else>
<!-- Statistics -->
<CronStats
:total="store.safeJobs.length"
:active="store.activeJobs.length"
:paused="store.pausedJobs.length"
:failed="store.failedJobs.length"
/>
<!-- Jobs List -->
<div v-if="store.safeJobs.length === 0" class="flex flex-col items-center justify-center py-20 text-[#525866] bg-[#F4F3EB]/40 rounded-3xl border border-transparent">
<RiTimeLine class="h-10 w-10 mb-4 opacity-50" />
<h3 class="text-lg font-medium mb-2 text-[#171717]">{{ t('cron.empty.title') }}</h3>
<p class="text-[14px] text-center mb-6 max-w-md">
{{ t('cron.empty.description') }}
</p>
<button
@click="openCreateDialog"
class="rounded-full px-6 h-10 bg-[#2B7FFF] hover:bg-[#2B7FFF]/90 text-white flex items-center justify-center text-[13px] font-medium"
>
<RiAddLine class="h-4 w-4 mr-2" />
{{ t('cron.empty.create') }}
</button>
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<CronJobCard
v-for="job in store.safeJobs"
:key="job.id"
:job="job"
@toggle="(enabled) => handleToggle(job.id, enabled)"
@edit="() => openEditDialog(job)"
@delete="() => confirmDelete(job.id)"
@trigger="() => handleTrigger(job.id)"
/>
</div>
</template>
</div>
</div>
</div>
<!-- Create/Edit Dialog -->
<CronTaskDialog
v-model="dialogVisible"
:job="editingJob"
@save="handleSave"
@closed="handleDialogClose"
/>
</layout>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
RiRefreshLine,
RiAddLine,
RiErrorWarningLine,
RiTimeLine,
} from '@remixicon/vue';
import { useCronStore } from '@src/store/cron';
import type { CronJob, CronJobCreateInput } from '@lib/cron-types';
import CronStats from './components/CronStats.vue';
import CronJobCard from './components/CronJobCard.vue';
import CronTaskDialog from './components/CronTaskDialog.vue';
const { t } = useI18n();
const store = useCronStore();
const dialogVisible = ref(false);
const editingJob = ref<CronJob | undefined>(undefined);
onMounted(() => {
store.fetchJobs();
});
function openCreateDialog() {
editingJob.value = undefined;
dialogVisible.value = true;
}
function openEditDialog(job: CronJob) {
editingJob.value = job;
dialogVisible.value = true;
}
function handleDialogClose() {
editingJob.value = undefined;
}
async function handleSave(input: CronJobCreateInput) {
try {
if (editingJob.value) {
await store.updateJob(editingJob.value.id, input);
ElMessage.success(t('cron.toast.updated'));
} else {
await store.createJob(input);
ElMessage.success(t('cron.toast.created'));
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ElMessage.error(msg);
}
}
async function handleToggle(id: string, enabled: boolean) {
try {
await store.toggleJob(id, enabled);
ElMessage.success(enabled ? t('cron.toast.enabled') : t('cron.toast.paused'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ElMessage.error(msg || t('cron.toast.failedUpdate'));
}
}
async function handleTrigger(id: string) {
try {
await store.triggerJob(id);
ElMessage.success(t('cron.toast.triggered'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
ElMessage.error(t('cron.toast.failedTrigger', { error: msg }));
}
}
async function confirmDelete(id: string) {
try {
await ElMessageBox.confirm(
t('cron.card.deleteConfirm'),
t('common.confirm', 'Confirm'),
{
confirmButtonText: t('common.delete', 'Delete'),
cancelButtonText: t('common.cancel', 'Cancel'),
type: 'warning',
},
);
await store.deleteJob(id);
ElMessage.success(t('cron.toast.deleted'));
} catch (err) {
// User cancelled or error
if (err !== 'cancel' && !(err instanceof Error && err.message === 'cancel')) {
const msg = err instanceof Error ? err.message : String(err);
if (msg && msg !== 'cancel') {
ElMessage.error(msg || t('cron.toast.failedDelete'));
}
}
}
}
</script>

158
src/pages/cron/utils.ts Normal file
View File

@@ -0,0 +1,158 @@
/**
* Cron utilities
* Parse schedules and estimate next run times
*/
import type { CronSchedule } from '@lib/cron-types';
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom';
export interface SchedulePreset {
key: string;
value: string;
type: ScheduleType;
}
export const schedulePresets: SchedulePreset[] = [
{ key: 'everyMinute', value: '* * * * *', type: 'interval' },
{ key: 'every5Min', value: '*/5 * * * *', type: 'interval' },
{ key: 'every15Min', value: '*/15 * * * *', type: 'interval' },
{ key: 'everyHour', value: '0 * * * *', type: 'interval' },
{ key: 'daily9am', value: '0 9 * * *', type: 'daily' },
{ key: 'daily6pm', value: '0 18 * * *', type: 'daily' },
{ key: 'weeklyMon', value: '0 9 * * 1', type: 'weekly' },
{ key: 'monthly1st', value: '0 9 1 * *', type: 'monthly' },
];
// Parse cron schedule to human-readable format
// Handles both plain cron strings and Gateway CronSchedule objects
export function parseCronSchedule(
schedule: unknown,
t: (key: string, interpolations?: Record<string, unknown>) => string,
): string {
if (schedule && typeof schedule === 'object') {
const s = schedule as { kind?: string; expr?: string; tz?: string; everyMs?: number; at?: string };
if (s.kind === 'cron' && typeof s.expr === 'string') {
return parseCronExpr(s.expr, t);
}
if (s.kind === 'every' && typeof s.everyMs === 'number') {
const ms = s.everyMs;
if (ms < 60_000) return t('cron.schedule.everySeconds', { count: Math.round(ms / 1000) });
if (ms < 3_600_000) return t('cron.schedule.everyMinutes', { count: Math.round(ms / 60_000) });
if (ms < 86_400_000) return t('cron.schedule.everyHours', { count: Math.round(ms / 3_600_000) });
return t('cron.schedule.everyDays', { count: Math.round(ms / 86_400_000) });
}
if (s.kind === 'at' && typeof s.at === 'string') {
try {
return t('cron.schedule.onceAt', { time: new Date(s.at).toLocaleString() });
} catch {
return t('cron.schedule.onceAt', { time: s.at });
}
}
return String(schedule);
}
if (typeof schedule === 'string') {
return parseCronExpr(schedule, t);
}
return String(schedule ?? t('cron.schedule.unknown'));
}
export function parseCronExpr(
cron: string,
t: (key: string, interpolations?: Record<string, unknown>) => string,
): string {
const preset = schedulePresets.find((p) => p.value === cron);
if (preset) return t(`cron.presets.${preset.key}` as const);
const parts = cron.split(' ');
if (parts.length !== 5) return cron;
const [minute, hour, dayOfMonth, , dayOfWeek] = parts;
if (minute === '*' && hour === '*') return t('cron.presets.everyMinute');
if (minute.startsWith('*/')) return t('cron.schedule.everyMinutes', { count: Number(minute.slice(2)) });
if (hour === '*' && minute === '0') return t('cron.presets.everyHour');
if (dayOfWeek !== '*' && dayOfMonth === '*') {
return t('cron.schedule.weeklyAt', { day: dayOfWeek, time: `${hour}:${minute.padStart(2, '0')}` });
}
if (dayOfMonth !== '*') {
return t('cron.schedule.monthlyAtDay', { day: dayOfMonth, time: `${hour}:${minute.padStart(2, '0')}` });
}
if (hour !== '*') {
return t('cron.schedule.dailyAt', { time: `${hour}:${minute.padStart(2, '0')}` });
}
return cron;
}
export function estimateNextRun(scheduleExpr: string): string | null {
const now = new Date();
const next = new Date(now.getTime());
if (scheduleExpr === '* * * * *') {
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + 1);
return formatLocaleDateTime(next);
}
if (scheduleExpr === '*/5 * * * *') {
const delta = 5 - (next.getMinutes() % 5 || 5);
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + delta);
return formatLocaleDateTime(next);
}
if (scheduleExpr === '*/15 * * * *') {
const delta = 15 - (next.getMinutes() % 15 || 15);
next.setSeconds(0, 0);
next.setMinutes(next.getMinutes() + delta);
return formatLocaleDateTime(next);
}
if (scheduleExpr === '0 * * * *') {
next.setMinutes(0, 0, 0);
next.setHours(next.getHours() + 1);
return formatLocaleDateTime(next);
}
if (scheduleExpr === '0 9 * * *' || scheduleExpr === '0 18 * * *') {
const targetHour = scheduleExpr === '0 9 * * *' ? 9 : 18;
next.setSeconds(0, 0);
next.setHours(targetHour, 0, 0, 0);
if (next <= now) next.setDate(next.getDate() + 1);
return formatLocaleDateTime(next);
}
if (scheduleExpr === '0 9 * * 1') {
next.setSeconds(0, 0);
next.setHours(9, 0, 0, 0);
const day = next.getDay();
const daysUntilMonday = day === 1 ? 7 : (8 - day) % 7;
next.setDate(next.getDate() + daysUntilMonday);
return formatLocaleDateTime(next);
}
if (scheduleExpr === '0 9 1 * *') {
next.setSeconds(0, 0);
next.setDate(1);
next.setHours(9, 0, 0, 0);
if (next <= now) next.setMonth(next.getMonth() + 1);
return formatLocaleDateTime(next);
}
return null;
}
function formatLocaleDateTime(date: Date): string {
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
} catch {
return date.toLocaleString();
}
}