From 2446d9107fe0b074bdf2ab2180fa2d2f3da99aec Mon Sep 17 00:00:00 2001 From: ChuanchuanSong Date: Mon, 16 Mar 2026 00:13:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=AE=9A=E6=97=B6?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=AE=A1=E7=90=86=E9=A1=B5=E9=9D=A2=20/cron?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 CronPage:任务卡片列表(启用 Toggle、编辑、删除) - 卡片展开显示执行记录(状态、耗时、结果预览、查看日志按钮) - 执行日志 Modal:完整 LLM 对话 + 工具调用可视化 - 新建/编辑 Modal:Cron 表达式输入 + 预设快速选择 + 实时验证 - 支持指定 SubAgent、完成通知主 Agent、立即启用等配置 - 侧边栏新增「定时任务」入口(CalendarClock 图标) - App.tsx 注册 /cron 路由 Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/App.tsx | 2 + frontend/src/components/Layout.tsx | 3 +- frontend/src/pages/CronPage.tsx | 787 +++++++++++++++++++++++++++++ 3 files changed, 791 insertions(+), 1 deletion(-) create mode 100644 frontend/src/pages/CronPage.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index aee6492..694f50c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import ChatPage from "./pages/ChatPage"; import TasksPage from "./pages/TasksPage"; import ToolsPage from "./pages/ToolsPage"; import SkillsPage from "./pages/SkillsPage"; +import CronPage from "./pages/CronPage"; import WorkspacePage from "./pages/WorkspacePage"; import SessionsPage from "./pages/SessionsPage"; import DashboardPage from "./pages/DashboardPage"; @@ -17,6 +18,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 77e7f33..9bd50d3 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -1,5 +1,5 @@ import { Link, Outlet, useLocation } from "react-router-dom"; -import { MessageCircle, ListTodo, Wrench, Puzzle, FolderClosed, MessagesSquare, BarChart3 } from "lucide-react"; +import { MessageCircle, ListTodo, Wrench, Puzzle, FolderClosed, MessagesSquare, BarChart3, CalendarClock } from "lucide-react"; import clsx from "clsx"; import NimoIcon from "./NimoIcon"; @@ -10,6 +10,7 @@ export default function Layout() { { to: "/sessions", icon: MessagesSquare, label: "会话" }, { to: "/tools", icon: Wrench, label: "工具" }, { to: "/skills", icon: Puzzle, label: "技能" }, + { to: "/cron", icon: CalendarClock, label: "定时任务" }, { to: "/tasks", icon: ListTodo, label: "任务" }, { to: "/workspace", icon: FolderClosed, label: "工作空间" }, { to: "/dashboard", icon: BarChart3, label: "监控" }, diff --git a/frontend/src/pages/CronPage.tsx b/frontend/src/pages/CronPage.tsx new file mode 100644 index 0000000..857bb33 --- /dev/null +++ b/frontend/src/pages/CronPage.tsx @@ -0,0 +1,787 @@ +import { useState, useCallback } from "react"; +import { + CalendarClock, Plus, Trash2, Edit2, ChevronDown, ChevronRight, + CheckCircle2, XCircle, Loader2, Clock, Bot, X, Terminal, + MessageSquare, RefreshCw, +} from "lucide-react"; +import clsx from "clsx"; +import { + useCronJobs, + useCreateCronJob, + useUpdateCronJob, + useDeleteCronJob, + useToggleCronJob, + useCronExecutions, + useCronExecutionDetail, +} from "../hooks/useCron"; +import type { CronJobInfo, CronJobCreate, CronJobUpdate } from "../api"; + +// ── Helpers ──────────────────────────────────────────────── + +function formatRelativeTime(isoStr: string | null): string { + if (!isoStr) return "—"; + const diff = new Date(isoStr).getTime() - Date.now(); + const abs = Math.abs(diff); + if (abs < 60_000) return diff > 0 ? "即将执行" : "刚刚"; + if (abs < 3_600_000) { const m = Math.round(abs / 60_000); return diff > 0 ? `${m} 分钟后` : `${m} 分钟前`; } + if (abs < 86_400_000) { const h = Math.round(abs / 3_600_000); return diff > 0 ? `${h} 小时后` : `${h} 小时前`; } + const d = Math.round(abs / 86_400_000); + return diff > 0 ? `${d} 天后` : `${d} 天前`; +} + +function formatDuration(start: string | null, end: string | null): string { + if (!start || !end) return ""; + const ms = new Date(end).getTime() - new Date(start).getTime(); + if (ms < 1_000) return `${ms}ms`; + if (ms < 60_000) return `${(ms / 1_000).toFixed(1)}s`; + return `${Math.round(ms / 60_000)}m`; +} + +const SCHEDULE_PRESETS = [ + { label: "每分钟", value: "* * * * *" }, + { label: "每 5 分钟", value: "*/5 * * * *" }, + { label: "每 15 分钟", value: "*/15 * * * *" }, + { label: "每小时", value: "0 * * * *" }, + { label: "每天 09:00", value: "0 9 * * *" }, + { label: "每天 00:00", value: "0 0 * * *" }, + { label: "每周一 09:00", value: "0 9 * * 1" }, + { label: "每月 1 日 09:00", value: "0 9 1 * *" }, +]; + +function describeSchedule(s: string): string { + return SCHEDULE_PRESETS.find((p) => p.value === s)?.label ?? s; +} + +const STATUS_CFG = { + pending: { Icon: Clock, color: "text-yellow-500", label: "等待中" }, + running: { Icon: Loader2, color: "text-blue-500", label: "执行中" }, + done: { Icon: CheckCircle2, color: "text-green-500", label: "已完成" }, + failed: { Icon: XCircle, color: "text-red-500", label: "失败" }, +} as const; + +type StatusKey = keyof typeof STATUS_CFG; + +// ── Toggle Switch ────────────────────────────────────────── +function Toggle({ enabled, onChange, disabled }: { + enabled: boolean; + onChange: (v: boolean) => void; + disabled?: boolean; +}) { + return ( + + ); +} + +// ── Execution Detail Modal ───────────────────────────────── +function ExecutionDetailModal({ executionId, onClose }: { + executionId: string; + onClose: () => void; +}) { + const { data, isLoading } = useCronExecutionDetail(executionId); + const cfg = STATUS_CFG[(data?.status as StatusKey) ?? "pending"]; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+ +
+

+ {data?.cron_job_name ?? "执行详情"} +

+ {data && ( +

+ {new Date(data.started_at ?? "").toLocaleString("zh-CN")} + {data.finished_at && ( + + · 耗时 {formatDuration(data.started_at, data.finished_at)} + + )} +

+ )} +
+ {cfg && ( + + + {cfg.label} + + )} + +
+ + {/* Body */} +
+ {isLoading ? ( +
+ +
+ ) : !data ? ( +

加载失败

+ ) : ( + <> + {/* Result */} + {data.result && ( +
+

执行结果

+

{data.result}

+
+ )} + + {/* Error */} + {data.error && ( +
+

错误信息

+

{data.error}

+
+ )} + + {/* Execution log */} + {data.execution_log.length > 0 && ( +
+

+ 执行日志 +

+
+ {data.execution_log.map((entry, i) => { + const role = (entry.role as string) ?? ""; + const content = (entry.content as string) ?? ""; + const toolCalls = entry.tool_calls as + | Array<{ function: { name: string; arguments: string } }> + | undefined; + + if (role === "system") return null; + + if (role === "user") { + return ( +
+
+ +
+
+ {content} +
+
+ ); + } + + if (role === "assistant") { + return ( +
+ {content && ( +
+
+ +
+
+ {content} +
+
+ )} + {toolCalls?.map((tc, j) => { + let args = tc.function.arguments; + try { args = JSON.stringify(JSON.parse(args), null, 2); } catch { /* keep raw */ } + return ( +
+
+ {tc.function.name} +
{args}
+
+
+ ); + })} +
+ ); + } + + if (role === "tool") { + return ( +
+
+ +
+
+ {content} +
+
+ ); + } + + return null; + })} +
+
+ )} + + {data.execution_log.length === 0 && !data.result && !data.error && ( +

暂无日志记录

+ )} + + )} +
+
+
+ ); +} + +// ── Execution List ───────────────────────────────────────── +function ExecutionList({ jobId }: { jobId: string }) { + const { data: executions = [], isLoading } = useCronExecutions(jobId); + const [detailId, setDetailId] = useState(null); + + if (isLoading) { + return ( +
+ 加载执行记录… +
+ ); + } + + if (executions.length === 0) { + return

暂无执行记录

; + } + + return ( + <> +
+ {executions.map((exec) => { + const cfg = STATUS_CFG[(exec.status as StatusKey) ?? "pending"]; + return ( +
+ +
+
+ {cfg.label} + {exec.agent_name && ( + + {exec.agent_name} + + )} + + {exec.started_at + ? new Date(exec.started_at).toLocaleString("zh-CN", { + month: "numeric", day: "numeric", + hour: "2-digit", minute: "2-digit", + }) + : "—"} + + {exec.finished_at && ( + + {formatDuration(exec.started_at, exec.finished_at)} + + )} +
+ {exec.result && ( +

{exec.result}

+ )} + {exec.error && ( +

{exec.error}

+ )} +
+ +
+ ); + })} +
+ + {detailId && ( + setDetailId(null)} + /> + )} + + ); +} + +// ── Cron Form Modal ──────────────────────────────────────── +interface FormState { + name: string; + schedule: string; + task_prompt: string; + agent_name: string; + enabled: boolean; + notify_main: boolean; +} + +const EMPTY_FORM: FormState = { + name: "", + schedule: "0 9 * * *", + task_prompt: "", + agent_name: "", + enabled: true, + notify_main: true, +}; + +function CronFormModal({ + initial, + onClose, + onSubmit, + submitting, +}: { + initial?: Partial; + onClose: () => void; + onSubmit: (data: CronJobCreate) => void; + submitting: boolean; +}) { + const isEdit = initial !== undefined; + const [form, setForm] = useState({ + ...EMPTY_FORM, + ...initial, + agent_name: initial?.agent_name ?? "", + }); + const [scheduleError, setScheduleError] = useState(""); + + const validateSchedule = (s: string): boolean => { + if (s.trim().split(/\s+/).length !== 5) { + setScheduleError("需要 5 个字段(分 时 日 月 周),如:0 9 * * *"); + return false; + } + setScheduleError(""); + return true; + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!form.name.trim() || !form.task_prompt.trim()) return; + if (!validateSchedule(form.schedule)) return; + onSubmit({ + name: form.name.trim(), + schedule: form.schedule.trim(), + task_prompt: form.task_prompt.trim(), + agent_name: form.agent_name.trim() || null, + enabled: form.enabled, + notify_main: form.notify_main, + }); + }; + + const set = (k: K, v: FormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+

+ {isEdit ? "编辑定时任务" : "新建定时任务"} +

+ +
+ +
+ {/* Name */} +
+ + set("name", e.target.value)} + placeholder="如:每日早报摘要" + required + className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm outline-none focus:border-nimo-400 transition-colors" + /> +
+ + {/* Schedule */} +
+ +
+ { + set("schedule", e.target.value); + if (scheduleError) validateSchedule(e.target.value); + }} + placeholder="0 9 * * *" + required + className={clsx( + "flex-1 rounded-lg border px-3 py-2 text-sm font-mono outline-none transition-colors", + scheduleError + ? "border-red-300 focus:border-red-400" + : "border-gray-200 focus:border-nimo-400", + )} + /> + +
+ {scheduleError + ?

{scheduleError}

+ : form.schedule && ( +

+ ≈ {describeSchedule(form.schedule)} +

+ ) + } +
+ + {/* Task prompt */} +
+ +