diff --git a/src/app/api/tasks/[id]/route.ts b/src/app/api/tasks/[id]/route.ts index 5a19815a..af020b91 100644 --- a/src/app/api/tasks/[id]/route.ts +++ b/src/app/api/tasks/[id]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; -import { updateTask, deleteTask, getTask, getScheduledTask, deleteScheduledTask } from '@/lib/db'; +import { updateTask, deleteTask, getTask, getScheduledTask, deleteScheduledTask, updateScheduledTask } from '@/lib/db'; import type { TaskResponse, ErrorResponse, UpdateTaskRequest } from '@/types'; +import type { ScheduledTask } from '@/types'; interface RouteContext { params: Promise<{ id: string }>; @@ -37,9 +38,17 @@ export async function PATCH(request: NextRequest, context: RouteContext) { const { id } = await context.params; try { - const body: UpdateTaskRequest = await request.json(); - const existing = getTask(id); + const body: UpdateTaskRequest & Partial = await request.json(); + // Try scheduled task first + const scheduledTask = getScheduledTask(id); + if (scheduledTask) { + const updated = updateScheduledTask(id, body); + return NextResponse.json({ task: scheduledTask }); + } + + // Fall back to regular task + const existing = getTask(id); if (!existing) { return NextResponse.json( { error: 'Task not found' }, diff --git a/src/app/scheduled-tasks/page.tsx b/src/app/scheduled-tasks/page.tsx new file mode 100644 index 00000000..1193a77f --- /dev/null +++ b/src/app/scheduled-tasks/page.tsx @@ -0,0 +1,11 @@ +"use client"; + +import { ScheduledTasksManager } from "@/components/scheduled-tasks/ScheduledTasksManager"; + +export default function ScheduledTasksPage() { + return ( +
+ +
+ ); +} diff --git a/src/components/layout/NavRail.tsx b/src/components/layout/NavRail.tsx index d4f2d5b8..63e6b036 100644 --- a/src/components/layout/NavRail.tsx +++ b/src/components/layout/NavRail.tsx @@ -10,6 +10,7 @@ import { Gear, WifiHigh, Terminal, + Clock, } from "@/components/ui/icon"; import { Button } from "@/components/ui/button"; import { @@ -35,6 +36,7 @@ const navItems = [ { href: "/skills", label: "Skills", icon: Lightning }, { href: "/mcp", label: "MCP", icon: Plug }, { href: "/cli-tools", label: "CLI Tools", icon: Terminal }, + { href: "/scheduled-tasks", label: "Scheduled Tasks", icon: Clock }, { href: "/gallery", label: "Gallery", icon: Image }, { href: "/bridge", label: "Bridge", icon: WifiHigh }, ] as const; @@ -50,6 +52,7 @@ export function NavRail({ onToggleChatList, hasUpdate, readyToInstall, skipPermi 'Gallery': 'gallery.title', 'Bridge': 'nav.bridge', 'CLI Tools': 'nav.cliTools', + 'Scheduled Tasks': 'nav.scheduledTasks', }; const isChatRoute = pathname === "/chat" || pathname.startsWith("/chat/"); const isSettingsActive = pathname === "/settings" || pathname.startsWith("/settings/"); diff --git a/src/components/scheduled-tasks/ScheduledTasksManager.tsx b/src/components/scheduled-tasks/ScheduledTasksManager.tsx new file mode 100644 index 00000000..bee25d85 --- /dev/null +++ b/src/components/scheduled-tasks/ScheduledTasksManager.tsx @@ -0,0 +1,467 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { useTranslation } from "@/hooks/useTranslation"; +import type { TranslationKey } from "@/i18n"; +import type { ScheduledTask } from "@/types"; +import { Clock, Play, Pause, Trash, Plus, SpinnerGap, CheckCircle, WarningCircle, Info } from "@/components/ui/icon"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + DialogFooter, + DialogDescription, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription } from "@/components/ui/alert"; + +export function ScheduledTasksManager() { + const { t, locale } = useTranslation(); + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [detailTask, setDetailTask] = useState(null); + + // Form state + const [newTaskName, setNewTaskName] = useState(""); + const [newTaskPrompt, setNewTaskPrompt] = useState(""); + const [scheduleType, setScheduleType] = useState<"cron" | "interval" | "once">("interval"); + const [scheduleValue, setScheduleValue] = useState("1h"); + const [priority, setPriority] = useState<"low" | "normal" | "urgent">("normal"); + const [notifyOnComplete, setNotifyOnComplete] = useState(true); + const [workingDirectory, setWorkingDirectory] = useState(""); + + const fetchTasks = useCallback(async () => { + try { + const res = await fetch("/api/tasks/list"); + const data = await res.json(); + setTasks(data.tasks || []); + } catch (err) { + console.error("Failed to fetch scheduled tasks:", err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchTasks(); + }, [fetchTasks]); + + const handleCreateTask = async () => { + if (!newTaskName || !newTaskPrompt || !scheduleValue) { + return; + } + + try { + const res = await fetch("/api/tasks/schedule", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: newTaskName, + prompt: newTaskPrompt, + schedule_type: scheduleType, + schedule_value: scheduleValue, + priority, + notify_on_complete: notifyOnComplete ? 1 : 0, + working_directory: workingDirectory || null, + }), + }); + + if (res.ok) { + setCreateDialogOpen(false); + // Reset form + setNewTaskName(""); + setNewTaskPrompt(""); + setScheduleType("interval"); + setScheduleValue("1h"); + setPriority("normal"); + setNotifyOnComplete(true); + setWorkingDirectory(""); + fetchTasks(); + } + } catch (err) { + console.error("Failed to create task:", err); + } + }; + + const handleToggleTask = async (task: ScheduledTask) => { + try { + const newStatus = task.status === "active" ? "paused" : "active"; + await fetch(`/api/tasks/${task.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + fetchTasks(); + } catch (err) { + console.error("Failed to toggle task:", err); + } + }; + + const handleDeleteTask = async (taskId: string) => { + try { + await fetch(`/api/tasks/${taskId}`, { method: "DELETE" }); + fetchTasks(); + } catch (err) { + console.error("Failed to delete task:", err); + } + }; + + const handleRunTask = async (task: ScheduledTask) => { + try { + await fetch(`/api/tasks/${task.id}/run`, { method: "POST" }); + fetchTasks(); + } catch (err) { + console.error("Failed to run task:", err); + } + }; + + const getStatusColor = (status: ScheduledTask["status"]) => { + switch (status) { + case "active": return "bg-status-success"; + case "paused": return "bg-status-warning"; + case "completed": return "bg-status-info"; + case "disabled": return "bg-status-error"; + default: return "bg-muted"; + } + }; + + const getStatusText = (status: ScheduledTask["status"]) => { + switch (status) { + case "active": return t("tasks.active"); + case "paused": return t("tasks.paused"); + case "completed": return t("tasks.completed"); + case "disabled": return t("tasks.failed"); + default: return status; + } + }; + + const formatNextRun = (nextRun: string) => { + const date = new Date(nextRun); + const now = new Date(); + const diff = date.getTime() - now.getTime(); + + if (diff < 0) return t("tasks.overdue"); + + const minutes = Math.floor(diff / 60000); + const hours = Math.floor(diff / 3600000); + const days = Math.floor(diff / 86400000); + + if (minutes < 60) return `${t("tasks.in")} ${minutes} ${t("tasks.minutes")}`; + if (hours < 24) return `${t("tasks.in")} ${hours} ${t("tasks.hours")}`; + return `${t("tasks.in")} ${days} ${t("tasks.days")}`; + }; + + if (loading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Fixed header */} +
+
+
+

{t("tasks.title")}

+

+ {t("tasks.description") || "管理和查看 Claude Code 定时任务"} +

+
+ + + + + + + {t("tasks.createTask")} + + {t("tasks.createDescription") || "创建一个新的定时任务"} + + +
+
+ + setNewTaskName(e.target.value)} + /> +
+
+ +