feat: 新增定时任务功能
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-[80px] h-full box-border flex flex-col items-center pb-[8px]">
|
||||
<div :class="['flex flex-col gap-[16px]', { 'mt-auto mb-[8px] shrink-1': item.id === 5 }]"
|
||||
<div :class="['flex flex-col gap-[16px]', { 'mt-auto mb-[8px] shrink-1': item.url === '/setting' }]"
|
||||
v-for="(item) in menus" :key="item.id">
|
||||
<div :class="['cursor-pointer flex flex-col items-center justify-center']" @click="handleClick(item)">
|
||||
<div :class="['box-border rounded-[16px] w-[48px] h-[48px] flex flex-col items-center justify-center hover:bg-white', { 'bg-white': item.id === currentId }]">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RiHomeLine, RiFileEditLine, RiCpuLine, RiSettingsLine, RiPuzzle2Line } from '@remixicon/vue'
|
||||
import { RiHomeLine, RiFileEditLine, RiCpuLine, RiSettingsLine, RiPuzzle2Line, RiTimeLine } from '@remixicon/vue'
|
||||
|
||||
// 菜单列表申明
|
||||
export interface MenuItem {
|
||||
@@ -45,6 +45,14 @@ export const menus: MenuItem[] = [
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: '定时任务',
|
||||
icon: RiTimeLine,
|
||||
color: '#525866',
|
||||
activeColor: '#2B7FFF',
|
||||
url: '/cron',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: '设置',
|
||||
icon: RiSettingsLine,
|
||||
color: '#525866',
|
||||
|
||||
@@ -21,5 +21,6 @@ export const NAMESPACES = [
|
||||
'component',
|
||||
'models',
|
||||
'skills',
|
||||
'cron',
|
||||
] as const;
|
||||
export type Namespace = (typeof NAMESPACES)[number];
|
||||
97
src/i18n/locales/en/cron.json
Normal file
97
src/i18n/locales/en/cron.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "Scheduled Tasks",
|
||||
"subtitle": "Automate AI workflows with scheduled tasks",
|
||||
"newTask": "New Task",
|
||||
"refresh": "Refresh",
|
||||
"stats": {
|
||||
"total": "Total Tasks",
|
||||
"active": "Active",
|
||||
"paused": "Paused",
|
||||
"failed": "Failed"
|
||||
},
|
||||
"empty": {
|
||||
"title": "No scheduled tasks",
|
||||
"description": "Create scheduled tasks to automate AI workflows. Tasks can send messages, run queries, or perform actions at specified times.",
|
||||
"create": "Create Your First Task"
|
||||
},
|
||||
"card": {
|
||||
"runNow": "Run Now",
|
||||
"deleteConfirm": "Are you sure you want to delete this task?",
|
||||
"last": "Last",
|
||||
"next": "Next"
|
||||
},
|
||||
"dialog": {
|
||||
"createTitle": "Create Task",
|
||||
"editTitle": "Edit Task",
|
||||
"description": "Schedule an automated AI task",
|
||||
"taskName": "Task Name",
|
||||
"taskNamePlaceholder": "e.g., Morning briefing",
|
||||
"message": "Message / Prompt",
|
||||
"messagePlaceholder": "What should the AI do? e.g., Give me a summary of today's news and weather",
|
||||
"schedule": "Schedule",
|
||||
"cronPlaceholder": "Cron expression (e.g., 0 9 * * *)",
|
||||
"usePresets": "Use presets",
|
||||
"useCustomCron": "Use custom cron",
|
||||
"deliveryTitle": "Delivery",
|
||||
"deliveryDescription": "Choose whether this task stays in the app or is pushed to an external channel.",
|
||||
"deliveryModeNone": "In app only",
|
||||
"deliveryModeNoneDesc": "Run the task and keep the result in the app.",
|
||||
"deliveryModeAnnounce": "External channel",
|
||||
"deliveryModeAnnounceDesc": "Send the final result through a configured channel.",
|
||||
"deliveryChannel": "Channel",
|
||||
"channelUnsupportedTag": "Unsupported",
|
||||
"deliveryAccount": "Sending Account",
|
||||
"selectDeliveryAccount": "Select an account",
|
||||
"deliveryAccountDesc": "Uses the same configured account list shown on the Channels page.",
|
||||
"selectChannel": "Select a channel",
|
||||
"deliveryChannelUnsupported": "{{channel}} does not currently support scheduled outbound delivery.",
|
||||
"deliveryDefaultAccountHint": "Uses the channel's default account: {{account}}",
|
||||
"deliveryTarget": "Recipient / Target",
|
||||
"selectDeliveryTarget": "Select a delivery target",
|
||||
"loadingTargets": "Loading targets...",
|
||||
"currentTarget": "Current target",
|
||||
"noDeliveryTargets": "No delivery targets are available for the selected {{channel}} account.",
|
||||
"deliveryTargetDescAuto": "Select from targets discovered for the chosen channel account.",
|
||||
"enableImmediately": "Enable immediately",
|
||||
"enableImmediatelyDesc": "Start running this task after creation",
|
||||
"saveChanges": "Save Changes"
|
||||
},
|
||||
"presets": {
|
||||
"everyMinute": "Every minute",
|
||||
"every5Min": "Every 5 minutes",
|
||||
"every15Min": "Every 15 minutes",
|
||||
"everyHour": "Every hour",
|
||||
"daily9am": "Daily at 9am",
|
||||
"daily6pm": "Daily at 6pm",
|
||||
"weeklyMon": "Weekly (Mon 9am)",
|
||||
"monthly1st": "Monthly (1st at 9am)"
|
||||
},
|
||||
"toast": {
|
||||
"created": "Task created",
|
||||
"updated": "Task updated",
|
||||
"enabled": "Task enabled",
|
||||
"paused": "Task paused",
|
||||
"deleted": "Task deleted",
|
||||
"triggered": "Task triggered successfully",
|
||||
"failedTrigger": "Failed to trigger task: {{error}}",
|
||||
"failedUpdate": "Failed to update task",
|
||||
"failedDelete": "Failed to delete task",
|
||||
"nameRequired": "Please enter a task name",
|
||||
"messageRequired": "Please enter a message",
|
||||
"channelRequired": "Please select a channel",
|
||||
"deliveryChannelUnsupported": "{{channel}} does not support scheduled delivery yet",
|
||||
"deliveryTargetRequired": "Please enter a delivery target",
|
||||
"scheduleRequired": "Please select or enter a schedule"
|
||||
},
|
||||
"schedule": {
|
||||
"everySeconds": "Every {{count}}s",
|
||||
"everyMinutes": "Every {{count}} minutes",
|
||||
"everyHours": "Every {{count}} hours",
|
||||
"everyDays": "Every {{count}} days",
|
||||
"onceAt": "Once at {{time}}",
|
||||
"weeklyAt": "Weekly on {{day}} at {{time}}",
|
||||
"monthlyAtDay": "Monthly on day {{day}} at {{time}}",
|
||||
"dailyAt": "Daily at {{time}}",
|
||||
"unknown": "Unknown"
|
||||
}
|
||||
}
|
||||
94
src/i18n/locales/ja/cron.json
Normal file
94
src/i18n/locales/ja/cron.json
Normal file
@@ -0,0 +1,94 @@
|
||||
{
|
||||
"title": "定期タスク",
|
||||
"subtitle": "定期タスクでAIワークフローを自動化",
|
||||
"newTask": "新規タスク",
|
||||
"refresh": "更新",
|
||||
"stats": {
|
||||
"total": "タスク合計",
|
||||
"active": "有効",
|
||||
"paused": "停止中",
|
||||
"failed": "失敗"
|
||||
},
|
||||
"empty": {
|
||||
"title": "定期タスクがありません",
|
||||
"description": "定期タスクを作成してAIワークフローを自動化します。指定した時間にメッセージ送信、クエリ実行、アクション実行が可能です。",
|
||||
"create": "最初のタスクを作成"
|
||||
},
|
||||
"card": {
|
||||
"runNow": "今すぐ実行",
|
||||
"deleteConfirm": "このタスクを削除してもよろしいですか?",
|
||||
"last": "前回",
|
||||
"next": "次回"
|
||||
},
|
||||
"dialog": {
|
||||
"createTitle": "タスク作成",
|
||||
"editTitle": "タスク編集",
|
||||
"description": "自動化AIタスクをスケジュール",
|
||||
"taskName": "タスク名",
|
||||
"taskNamePlaceholder": "例:朝のブリーフィング",
|
||||
"message": "メッセージ / プロンプト",
|
||||
"messagePlaceholder": "AIに何をさせますか? 例:今日のニュースと天気のまとめを作成",
|
||||
"schedule": "スケジュール",
|
||||
"cronPlaceholder": "Cron式(例:0 9 * * *)",
|
||||
"usePresets": "プリセットを使用",
|
||||
"useCustomCron": "カスタムCronを使用",
|
||||
"deliveryTitle": "配信設定",
|
||||
"deliveryDescription": "結果をアプリ内だけに残すか、外部チャンネルへ送信するかを選びます。",
|
||||
"deliveryModeNone": "アプリ内のみ",
|
||||
"deliveryModeNoneDesc": "タスクを実行し、結果はアプリ内だけに残します。",
|
||||
"deliveryModeAnnounce": "外部チャンネル",
|
||||
"deliveryModeAnnounceDesc": "最終結果を設定済みチャンネルへ送信します。",
|
||||
"deliveryChannel": "チャンネル",
|
||||
"deliveryAccount": "送信アカウント",
|
||||
"selectDeliveryAccount": "アカウントを選択",
|
||||
"deliveryAccountDesc": "Channels ページと同じ設定済みアカウント一覧を使います。",
|
||||
"selectChannel": "チャンネルを選択",
|
||||
"deliveryDefaultAccountHint": "このチャンネルの既定アカウントを使います: {{account}}",
|
||||
"deliveryTarget": "送信先",
|
||||
"selectDeliveryTarget": "送信先を選択",
|
||||
"loadingTargets": "送信先を読み込み中...",
|
||||
"currentTarget": "現在の送信先",
|
||||
"noDeliveryTargets": "この {{channel}} アカウントでは選択可能な送信先が見つかりませんでした。",
|
||||
"deliveryTargetDescAuto": "選択したチャンネルアカウントで見つかった送信先から選べます。",
|
||||
"enableImmediately": "すぐに有効化",
|
||||
"enableImmediatelyDesc": "作成後すぐにこのタスクを実行開始",
|
||||
"saveChanges": "変更を保存"
|
||||
},
|
||||
"presets": {
|
||||
"everyMinute": "毎分",
|
||||
"every5Min": "5分ごと",
|
||||
"every15Min": "15分ごと",
|
||||
"everyHour": "1時間ごと",
|
||||
"daily9am": "毎日 午前9時",
|
||||
"daily6pm": "毎日 午後6時",
|
||||
"weeklyMon": "毎週 (月曜 午前9時)",
|
||||
"monthly1st": "毎月 (1日 午前9時)"
|
||||
},
|
||||
"toast": {
|
||||
"created": "タスクを作成しました",
|
||||
"updated": "タスクを更新しました",
|
||||
"enabled": "タスクを有効にしました",
|
||||
"paused": "タスクを停止しました",
|
||||
"deleted": "タスクを削除しました",
|
||||
"triggered": "タスクを正常にトリガーしました",
|
||||
"failedTrigger": "タスクの実行に失敗しました: {{error}}",
|
||||
"failedUpdate": "タスクの更新に失敗しました",
|
||||
"failedDelete": "タスクの削除に失敗しました",
|
||||
"nameRequired": "タスク名を入力してください",
|
||||
"messageRequired": "メッセージを入力してください",
|
||||
"channelRequired": "チャンネルを選択してください",
|
||||
"deliveryTargetRequired": "送信先を入力してください",
|
||||
"scheduleRequired": "スケジュールを選択または入力してください"
|
||||
},
|
||||
"schedule": {
|
||||
"everySeconds": "{{count}}秒ごと",
|
||||
"everyMinutes": "{{count}}分ごと",
|
||||
"everyHours": "{{count}}時間ごと",
|
||||
"everyDays": "{{count}}日ごと",
|
||||
"onceAt": "{{time}} に1回実行",
|
||||
"weeklyAt": "毎週 {{day}} {{time}}",
|
||||
"monthlyAtDay": "毎月 {{day}}日 {{time}}",
|
||||
"dailyAt": "毎日 {{time}}",
|
||||
"unknown": "不明"
|
||||
}
|
||||
}
|
||||
97
src/i18n/locales/zh/cron.json
Normal file
97
src/i18n/locales/zh/cron.json
Normal file
@@ -0,0 +1,97 @@
|
||||
{
|
||||
"title": "定时任务",
|
||||
"subtitle": "通过定时任务自动化 AI 工作流",
|
||||
"newTask": "新建任务",
|
||||
"refresh": "刷新",
|
||||
"stats": {
|
||||
"total": "任务总数",
|
||||
"active": "运行中",
|
||||
"paused": "已暂停",
|
||||
"failed": "失败"
|
||||
},
|
||||
"empty": {
|
||||
"title": "暂无定时任务",
|
||||
"description": "创建定时任务以自动化 AI 工作流。任务可以在指定时间发送消息、运行查询或执行操作。",
|
||||
"create": "创建第一个任务"
|
||||
},
|
||||
"card": {
|
||||
"runNow": "立即运行",
|
||||
"deleteConfirm": "确定要删除此任务吗?",
|
||||
"last": "上次运行",
|
||||
"next": "下次运行"
|
||||
},
|
||||
"dialog": {
|
||||
"createTitle": "创建任务",
|
||||
"editTitle": "编辑任务",
|
||||
"description": "安排自动化的 AI 任务",
|
||||
"taskName": "任务名称",
|
||||
"taskNamePlaceholder": "例如:早间简报",
|
||||
"message": "消息 / 提示词",
|
||||
"messagePlaceholder": "AI 应该做什么?例如:给我一份今天的新闻和天气摘要",
|
||||
"schedule": "调度计划",
|
||||
"cronPlaceholder": "Cron 表达式 (例如:0 9 * * *)",
|
||||
"usePresets": "使用预设",
|
||||
"useCustomCron": "使用自定义 Cron",
|
||||
"deliveryTitle": "投递设置",
|
||||
"deliveryDescription": "选择仅在应用内保留结果,或把最终结果推送到外部通道。",
|
||||
"deliveryModeNone": "仅应用内",
|
||||
"deliveryModeNoneDesc": "任务照常运行,结果只保留在应用内。",
|
||||
"deliveryModeAnnounce": "发送到外部通道",
|
||||
"deliveryModeAnnounceDesc": "将最终结果投递到已配置的消息通道。",
|
||||
"deliveryChannel": "通道",
|
||||
"channelUnsupportedTag": "暂不支持",
|
||||
"deliveryAccount": "发送账号",
|
||||
"selectDeliveryAccount": "选择账号",
|
||||
"deliveryAccountDesc": "这里直接复用 Channels 页面里的已配置账号列表。",
|
||||
"selectChannel": "选择通道",
|
||||
"deliveryChannelUnsupported": "{{channel}} 通道当前不支持定时任务主动投递。",
|
||||
"deliveryDefaultAccountHint": "将使用该通道当前的默认账号:{{account}}",
|
||||
"deliveryTarget": "接收目标",
|
||||
"selectDeliveryTarget": "选择接收目标",
|
||||
"loadingTargets": "正在加载目标...",
|
||||
"currentTarget": "当前目标",
|
||||
"noDeliveryTargets": "当前 {{channel}} 账号暂无可选投递目标。",
|
||||
"deliveryTargetDescAuto": "这里会展示该通道账号下已发现的可投递目标。",
|
||||
"enableImmediately": "立即启用",
|
||||
"enableImmediatelyDesc": "创建后立即开始运行此任务",
|
||||
"saveChanges": "保存更改"
|
||||
},
|
||||
"presets": {
|
||||
"everyMinute": "每分钟",
|
||||
"every5Min": "每 5 分钟",
|
||||
"every15Min": "每 15 分钟",
|
||||
"everyHour": "每小时",
|
||||
"daily9am": "每天上午 9 点",
|
||||
"daily6pm": "每天下午 6 点",
|
||||
"weeklyMon": "每周 (周一上午 9 点)",
|
||||
"monthly1st": "每月 (1号上午 9 点)"
|
||||
},
|
||||
"toast": {
|
||||
"created": "任务已创建",
|
||||
"updated": "任务已更新",
|
||||
"enabled": "任务已启用",
|
||||
"paused": "任务已暂停",
|
||||
"deleted": "任务已删除",
|
||||
"triggered": "任务已成功触发",
|
||||
"failedTrigger": "触发任务失败: {{error}}",
|
||||
"failedUpdate": "更新任务失败",
|
||||
"failedDelete": "删除任务失败",
|
||||
"nameRequired": "请输入任务名称",
|
||||
"messageRequired": "请输入消息",
|
||||
"channelRequired": "请选择通道",
|
||||
"deliveryChannelUnsupported": "{{channel}} 暂不支持定时任务投递",
|
||||
"deliveryTargetRequired": "请输入投递目标",
|
||||
"scheduleRequired": "请选择或输入调度计划"
|
||||
},
|
||||
"schedule": {
|
||||
"everySeconds": "每 {{count}} 秒",
|
||||
"everyMinutes": "每 {{count}} 分钟",
|
||||
"everyHours": "每 {{count}} 小时",
|
||||
"everyDays": "每 {{count}} 天",
|
||||
"onceAt": "执行一次,时间:{{time}}",
|
||||
"weeklyAt": "每周 {{day}} {{time}}",
|
||||
"monthlyAtDay": "每月 {{day}} 日 {{time}}",
|
||||
"dailyAt": "每天 {{time}}",
|
||||
"unknown": "未知"
|
||||
}
|
||||
}
|
||||
75
src/lib/cron-types.ts
Normal file
75
src/lib/cron-types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Cron Job Type Definitions
|
||||
* Types for scheduled tasks
|
||||
*/
|
||||
|
||||
export type CronJobDeliveryMode = 'none' | 'announce';
|
||||
|
||||
export interface CronJobDelivery {
|
||||
mode: CronJobDeliveryMode;
|
||||
channel?: string;
|
||||
to?: string;
|
||||
accountId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cron job last run info
|
||||
*/
|
||||
export interface CronJobLastRun {
|
||||
time: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gateway CronSchedule object format
|
||||
*/
|
||||
export type CronSchedule =
|
||||
| { kind: 'at'; at: string }
|
||||
| { kind: 'every'; everyMs: number; anchorMs?: number }
|
||||
| { kind: 'cron'; expr: string; tz?: string };
|
||||
|
||||
/**
|
||||
* Cron job data structure
|
||||
* schedule can be a plain cron string or a Gateway CronSchedule object
|
||||
*/
|
||||
export interface CronJob {
|
||||
id: string;
|
||||
name: string;
|
||||
message: string;
|
||||
schedule: string | CronSchedule;
|
||||
delivery?: CronJobDelivery;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastRun?: CronJobLastRun;
|
||||
nextRun?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for creating a cron job from the UI.
|
||||
*/
|
||||
export interface CronJobCreateInput {
|
||||
name: string;
|
||||
message: string;
|
||||
schedule: string;
|
||||
delivery?: CronJobDelivery;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Input for updating a cron job
|
||||
*/
|
||||
export interface CronJobUpdateInput {
|
||||
name?: string;
|
||||
message?: string;
|
||||
schedule?: string;
|
||||
delivery?: CronJobDelivery;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule type for UI picker
|
||||
*/
|
||||
export type ScheduleType = 'daily' | 'weekly' | 'monthly' | 'interval' | 'custom';
|
||||
168
src/pages/cron/components/CronJobCard.vue
Normal file
168
src/pages/cron/components/CronJobCard.vue
Normal 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>
|
||||
74
src/pages/cron/components/CronStats.vue
Normal file
74
src/pages/cron/components/CronStats.vue
Normal 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>
|
||||
406
src/pages/cron/components/CronTaskDialog.vue
Normal file
406
src/pages/cron/components/CronTaskDialog.vue
Normal 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
206
src/pages/cron/index.vue
Normal 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
158
src/pages/cron/utils.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,18 @@ const routes = [
|
||||
name: "Skills",
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/skills",
|
||||
component: () => import("@src/pages/skills/index.vue"),
|
||||
name: "Skills",
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/cron",
|
||||
component: () => import("@src/pages/cron/index.vue"),
|
||||
name: "Cron",
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/setting",
|
||||
component: () => import("@src/pages/setting/index.vue"),
|
||||
|
||||
145
src/store/cron.ts
Normal file
145
src/store/cron.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Cron State Store
|
||||
* Manages scheduled task state
|
||||
*/
|
||||
import { defineStore } from 'pinia';
|
||||
import { ref, computed } from 'vue';
|
||||
import { hostApiFetch } from '@lib/host-api';
|
||||
import type { CronJob, CronJobCreateInput, CronJobUpdateInput } from '@lib/cron-types';
|
||||
|
||||
export const MOCK_CRON_JOBS: CronJob[] = [];
|
||||
|
||||
export const useCronStore = defineStore('cron', () => {
|
||||
const jobs = ref<CronJob[]>([]);
|
||||
const loading = ref(false);
|
||||
const error = ref<string | null>(null);
|
||||
|
||||
const safeJobs = computed(() => (Array.isArray(jobs.value) ? jobs.value : []));
|
||||
const activeJobs = computed(() => safeJobs.value.filter((j) => j.enabled));
|
||||
const pausedJobs = computed(() => safeJobs.value.filter((j) => !j.enabled));
|
||||
const failedJobs = computed(() => safeJobs.value.filter((j) => j.lastRun && !j.lastRun.success));
|
||||
|
||||
const fetchJobs = async () => {
|
||||
const currentJobs = safeJobs.value;
|
||||
if (currentJobs.length === 0) {
|
||||
loading.value = true;
|
||||
}
|
||||
error.value = null;
|
||||
|
||||
try {
|
||||
const result = await hostApiFetch<CronJob[]>('/api/cron/jobs');
|
||||
jobs.value = result;
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : String(err);
|
||||
// Fallback to mock data on error for demo/development
|
||||
jobs.value = MOCK_CRON_JOBS;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const createJob = async (input: CronJobCreateInput) => {
|
||||
const job: CronJob = {
|
||||
id: `local-${Date.now()}`,
|
||||
...input,
|
||||
enabled: input.enabled ?? true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
try {
|
||||
const result = await hostApiFetch<CronJob>('/api/cron/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
jobs.value = [...safeJobs.value, result];
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn('Failed to create cron job via API, using local fallback:', err);
|
||||
jobs.value = [...safeJobs.value, job];
|
||||
return job;
|
||||
}
|
||||
};
|
||||
|
||||
const updateJob = async (id: string, input: CronJobUpdateInput) => {
|
||||
try {
|
||||
const updatedJob = await hostApiFetch<CronJob>(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
jobs.value = safeJobs.value.map((job) => (job.id === id ? updatedJob : job));
|
||||
} catch (err) {
|
||||
console.warn('Failed to update cron job via API, using local fallback:', err);
|
||||
jobs.value = safeJobs.value.map((job) =>
|
||||
job.id === id
|
||||
? { ...job, ...input, updatedAt: new Date().toISOString() }
|
||||
: job,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteJob = async (id: string) => {
|
||||
try {
|
||||
await hostApiFetch(`/api/cron/jobs/${encodeURIComponent(id)}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to delete cron job via API, using local fallback:', err);
|
||||
}
|
||||
jobs.value = safeJobs.value.filter((job) => job.id !== id);
|
||||
};
|
||||
|
||||
const toggleJob = async (id: string, enabled: boolean) => {
|
||||
try {
|
||||
await hostApiFetch('/api/cron/toggle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id, enabled }),
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to toggle cron job via API, using local fallback:', err);
|
||||
}
|
||||
jobs.value = safeJobs.value.map((job) =>
|
||||
job.id === id ? { ...job, enabled, updatedAt: new Date().toISOString() } : job,
|
||||
);
|
||||
};
|
||||
|
||||
const triggerJob = async (id: string) => {
|
||||
try {
|
||||
const result = await hostApiFetch('/api/cron/trigger', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
console.log('Cron trigger result:', result);
|
||||
} catch (err) {
|
||||
console.warn('Failed to trigger cron job via API, using local fallback:', err);
|
||||
}
|
||||
// Update lastRun locally as fallback
|
||||
jobs.value = safeJobs.value.map((job) =>
|
||||
job.id === id
|
||||
? {
|
||||
...job,
|
||||
lastRun: {
|
||||
time: new Date().toISOString(),
|
||||
success: true,
|
||||
},
|
||||
updatedAt: new Date().toISOString(),
|
||||
}
|
||||
: job,
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
jobs,
|
||||
loading,
|
||||
error,
|
||||
safeJobs,
|
||||
activeJobs,
|
||||
pausedJobs,
|
||||
failedJobs,
|
||||
fetchJobs,
|
||||
createJob,
|
||||
updateJob,
|
||||
deleteJob,
|
||||
toggleJob,
|
||||
triggerJob,
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user