diff --git a/db/schedules.go b/db/schedules.go index ce1bb1b..0183bcb 100644 --- a/db/schedules.go +++ b/db/schedules.go @@ -311,6 +311,11 @@ func (d *DB) TouchTaskSchedule(id int64, status, windowKey, lastError string) er return err } +func (d *DB) MarkTaskScheduleManual(id int64) error { + _, err := d.Exec(`UPDATE task_schedules SET status='running',last_window_key='manual',last_transition_at=now(),last_error='' WHERE id=$1`, id) + return err +} + // ScheduleRunStats summarizes completed schedule window entries for list views. // The scheduler records one "running" history entry whenever a new window opens. type ScheduleRunStats struct { diff --git a/server/schedules.go b/server/schedules.go index f6a17b1..67b83fa 100644 --- a/server/schedules.go +++ b/server/schedules.go @@ -199,23 +199,55 @@ func (s *Server) runScheduleNow(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "计划不存在") return } - s.runScheduleTasks(item) - writeJSON(w, 200, map[string]any{"ok": true}) + if err := s.m.pg.MarkTaskScheduleManual(id); err != nil { + writeErr(w, 500, "无法启动计划任务") + return + } + _ = s.m.pg.RecordScheduleHistory(id, 0, "running", true, "手动立即运行") + started, errors := s.runScheduleTasks(item) + writeJSON(w, 200, map[string]any{"ok": true, "started": started, "errors": errors}) } -func (s *Server) runScheduleTasks(item *db.TaskSchedule) { +func (s *Server) runScheduleTasks(item *db.TaskSchedule) (int, []string) { + started := 0 + errors := []string{} for _, taskID := range item.TaskIDs { t, ok := s.m.Task(strconv.FormatInt(taskID, 10)) if !ok { + errors = append(errors, fmt.Sprintf("任务 #%d 未找到", taskID)) continue } state := t.lifecycleSnapshot() - if db.IsTerminal(state.Status) || !state.Paused { + if db.IsTerminal(state.Status) { + errors = append(errors, fmt.Sprintf("任务 #%d 已结束,无法立即运行", taskID)) continue } - _, _ = s.applyTaskControlWithCause(t, "resume", agent.AbortPausedByOrchestrator) + if !state.Paused { + started++ + continue + } + managed, err := s.m.pg.SchedulePausedTasks(taskID) + if err != nil { + errors = append(errors, fmt.Sprintf("任务 #%d 状态读取失败", taskID)) + _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "状态读取失败") + continue + } + if !managed { + errors = append(errors, fmt.Sprintf("任务 #%d 由人工暂停,计划不能强制恢复", taskID)) + _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "人工暂停优先") + continue + } + _, err = s.applyTaskControlWithCause(t, "resume", agent.AbortPausedByOrchestrator) + if err != nil { + errors = append(errors, fmt.Sprintf("任务 #%d:%s", taskID, err.Error())) + _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, err.Error()) + continue + } + started++ _ = s.m.pg.SetSchedulePaused(item.ID, taskID, false) + _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, "立即运行") } + return started, errors } // scheduleRequestFromTask is used by the task creation handler without making diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index fb828fb..ddabb26 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -220,10 +220,20 @@ function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; load ); } -function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" { - if (status === "running") return "default"; - if (status === "error") return "destructive"; - return "secondary"; +function statusClass(status: TaskSchedule["status"]) { + return ( + { + waiting: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-300", + running: + "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300", + outside: + "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300", + completed: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300", + error: "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300", + paused: + "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300", + } as Record + )[status]; } function ScheduleForm({ @@ -515,7 +525,14 @@ export default function SchedulesPage() { try { if (type === "pause") await api.pauseSchedule(id); else if (type === "resume") await api.resumeSchedule(id); - else await api.runScheduleNow(id); + else { + const result = await api.runScheduleNow(id); + if (result.errors?.length) { + toast.warning(`已启动 ${result.started} 个任务,但有 ${result.errors.length} 个任务未启动`); + } else { + toast.success(`已立即运行 ${result.started} 个任务`); + } + } await load(); } catch (e) { toast.error((e as Error).message); @@ -633,7 +650,9 @@ export default function SchedulesPage() { {item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"} - {statusText(item)} + + {statusText(item)} +
{timezoneLabel(item)}
最近:{formatTime(item.last_transition_at)} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 4d2aad4..9a673f6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -328,7 +328,8 @@ export const api = { deleteSchedule: (id: number) => del<{ ok: boolean }>(`/schedules/${id}`), pauseSchedule: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/pause`, {}), resumeSchedule: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/resume`, {}), - runScheduleNow: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/run-now`, {}), + runScheduleNow: (id: number) => + post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}), taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)), updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch(`/tasks/${id}`, input), renameTask: (id: string, name: string) => patch(`/tasks/${id}`, { name }),