diff --git a/db/schedules.go b/db/schedules.go index 83e5235..ce1bb1b 100644 --- a/db/schedules.go +++ b/db/schedules.go @@ -85,35 +85,35 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) { in.TimezoneMode = "beijing" } if in.TimezoneMode != "beijing" && in.TimezoneMode != "system" { - return in, fmt.Errorf("timezone_mode 必须为 beijing 或 system") + return in, fmt.Errorf("时区必须选择北京时间或系统当前时区") } if in.ScheduleType != "once" && in.ScheduleType != "weekly" { - return in, fmt.Errorf("schedule_type 必须为 once 或 weekly") + return in, fmt.Errorf("规则必须选择一次性或按周") } if _, err := time.Parse("15:04", in.StartTime); err != nil { - return in, fmt.Errorf("start_time 必须为 HH:MM") + return in, fmt.Errorf("开始时间格式不正确,请使用 HH:MM") } if in.EndTime != "" && in.EndTime != "24:00" { if _, err := time.Parse("15:04", in.EndTime); err != nil { - return in, fmt.Errorf("end_time 必须为 HH:MM") + return in, fmt.Errorf("结束时间格式不正确,请使用 HH:MM") } } if in.ScheduleType == "once" { if _, err := time.Parse("2006-01-02", in.RunDate); err != nil { - return in, fmt.Errorf("run_date 必须为 YYYY-MM-DD") + return in, fmt.Errorf("开始日期格式不正确") } if in.EndDate != "" { startDate, _ := time.Parse("2006-01-02", in.RunDate) endDate, err := time.Parse("2006-01-02", in.EndDate) if err != nil { - return in, fmt.Errorf("end_date 必须为 YYYY-MM-DD") + return in, fmt.Errorf("截止日期格式不正确") } if endDate.Before(startDate) { - return in, fmt.Errorf("end_date 不能早于 run_date") + return in, fmt.Errorf("截止日期不能早于开始日期") } if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" { if in.EndTime <= in.StartTime { - return in, fmt.Errorf("同一天的 end_time 必须晚于 start_time") + return in, fmt.Errorf("同一天的结束时间必须晚于开始时间") } } } @@ -122,12 +122,12 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) { seen := map[int]bool{} for _, day := range in.Weekdays { if day < 1 || day > 7 || seen[day] { - return in, fmt.Errorf("weekdays 必须是 1-7 且不能重复") + return in, fmt.Errorf("星期选择必须为周一至周日且不能重复") } seen[day] = true } if len(in.Weekdays) == 0 { - return in, fmt.Errorf("weekly 计划至少选择一天") + return in, fmt.Errorf("按周计划至少选择一天") } sort.Ints(in.Weekdays) in.RunDate = "" @@ -136,7 +136,7 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) { seenTasks := map[int64]bool{} for _, id := range in.TaskIDs { if id <= 0 || seenTasks[id] { - return in, fmt.Errorf("任务编号无效或重复") + return in, fmt.Errorf("绑定任务编号无效或重复") } seenTasks[id] = true } @@ -311,6 +311,26 @@ func (d *DB) TouchTaskSchedule(id int64, status, windowKey, lastError string) er 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 { + RunCount int `json:"run_count"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` +} + +func (d *DB) ScheduleRunStats(id int64) (ScheduleRunStats, error) { + var stats ScheduleRunStats + var lastRunAt sql.NullTime + err := d.QueryRow(` + SELECT COUNT(*) FILTER (WHERE task_id IS NULL AND action='running'), + MAX(created_at) FILTER (WHERE task_id IS NULL AND action='running') + FROM task_schedule_history WHERE schedule_id=$1`, id).Scan(&stats.RunCount, &lastRunAt) + if lastRunAt.Valid { + stats.LastRunAt = &lastRunAt.Time + } + return stats, err +} + // TaskScheduleTasks returns all schedule IDs that reference a task. func (d *DB) TaskScheduleTasks(taskID int64) ([]int64, error) { rows, err := d.Query(`SELECT schedule_id FROM task_schedule_tasks WHERE task_id=$1 ORDER BY schedule_id`, taskID) diff --git a/server/schedules.go b/server/schedules.go index 573ee6d..f6a17b1 100644 --- a/server/schedules.go +++ b/server/schedules.go @@ -42,11 +42,17 @@ func parseScheduleID(r *http.Request) (int64, error) { return strconv.ParseInt(r.PathValue("id"), 10, 64) } -func scheduleDTO(item *db.TaskSchedule) map[string]any { +func (s *Server) scheduleDTO(item *db.TaskSchedule) map[string]any { raw, _ := json.Marshal(item) var dto map[string]any _ = json.Unmarshal(raw, &dto) window := evaluateScheduleWindow(item, time.Now()) + if stats, err := s.m.pg.ScheduleRunStats(item.ID); err == nil { + dto["run_count"] = stats.RunCount + if stats.LastRunAt != nil { + dto["last_run_at"] = stats.LastRunAt + } + } dto["timezone"] = scheduleLocation(item.TimezoneMode).String() dto["server_time"] = time.Now() if item.Enabled && item.Status != "completed" { @@ -63,7 +69,7 @@ func (s *Server) listSchedules(w http.ResponseWriter, r *http.Request) { } dtos := []map[string]any{} for _, item := range items { - dtos = append(dtos, scheduleDTO(item)) + dtos = append(dtos, s.scheduleDTO(item)) } writeJSON(w, 200, map[string]any{"schedules": dtos}) } @@ -71,7 +77,7 @@ func (s *Server) listSchedules(w http.ResponseWriter, r *http.Request) { func (s *Server) getSchedule(w http.ResponseWriter, r *http.Request) { id, err := parseScheduleID(r) if err != nil { - writeErr(w, 400, "bad schedule id") + writeErr(w, 400, "计划编号无效") return } item, err := s.m.pg.GetTaskSchedule(id) @@ -84,7 +90,7 @@ func (s *Server) getSchedule(w http.ResponseWriter, r *http.Request) { writeErr(w, 500, err.Error()) return } - dto := scheduleDTO(item) + dto := s.scheduleDTO(item) dto["history"] = history writeJSON(w, 200, dto) } @@ -121,7 +127,7 @@ func (s *Server) createSchedule(w http.ResponseWriter, r *http.Request) { func (s *Server) updateSchedule(w http.ResponseWriter, r *http.Request) { id, err := parseScheduleID(r) if err != nil { - writeErr(w, 400, "bad schedule id") + writeErr(w, 400, "计划编号无效") return } old, err := s.m.pg.GetTaskSchedule(id) @@ -145,7 +151,7 @@ func (s *Server) updateSchedule(w http.ResponseWriter, r *http.Request) { func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) { id, err := parseScheduleID(r) if err != nil { - writeErr(w, 400, "bad schedule id") + writeErr(w, 400, "计划编号无效") return } if err := s.m.pg.DeleteTaskSchedule(id); err != nil { @@ -158,7 +164,7 @@ func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) { func (s *Server) setScheduleEnabled(w http.ResponseWriter, r *http.Request, enabled bool) { id, err := parseScheduleID(r) if err != nil { - writeErr(w, 400, "bad schedule id") + writeErr(w, 400, "计划编号无效") return } if err := s.m.pg.SetTaskScheduleEnabled(id, enabled); err != nil { @@ -185,7 +191,7 @@ func (s *Server) resumeSchedule(w http.ResponseWriter, r *http.Request) { func (s *Server) runScheduleNow(w http.ResponseWriter, r *http.Request) { id, err := parseScheduleID(r) if err != nil { - writeErr(w, 400, "bad schedule id") + writeErr(w, 400, "计划编号无效") return } item, err := s.m.pg.GetTaskSchedule(id) diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index bc62628..31fba70 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -41,7 +41,7 @@ function statusText(s: TaskSchedule) { error: "错误", paused: "已暂停", } as Record - )[s.status] ?? s.status + )[s.status] ?? "未知状态" ); } @@ -49,6 +49,73 @@ function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN") : "—"; } +function formatDateValue(value?: string) { + if (!value) return "未设置"; + const [year, month, day] = value.split("-"); + return year && month && day ? `${year}年${Number(month)}月${Number(day)}日` : value; +} + +function formatClock(value?: string) { + return value ? value.slice(0, 5) : "未设置"; +} + +function periodLabel(item: TaskSchedule) { + if (item.schedule_type === "once") { + return item.end_date + ? `一次性 · ${formatDateValue(item.run_date)} 至 ${formatDateValue(item.end_date)}` + : `一次性 · ${formatDateValue(item.run_date)}`; + } + const selected = item.weekdays.map((day) => weekdays[day - 1]).filter(Boolean); + return `每周 · ${selected.length ? selected.join("、") : "未选择星期"}`; +} + +function windowLabel(item: TaskSchedule) { + const start = formatClock(item.start_time); + const end = formatClock(item.end_time); + if (item.schedule_type === "once") { + const startText = `${formatDateValue(item.run_date)} ${start}`; + if (item.end_date) return `${startText} 至 ${formatDateValue(item.end_date)} ${item.end_time ? end : "24:00"}`; + if (!item.end_time) return `${startText} 起持续运行`; + return `${startText} 至 ${end}${end <= start ? "(次日)" : ""}`; + } + if (!item.end_time) return `${start} 起持续运行`; + return `${start} 至 ${end}${end <= start ? "(次日)" : ""}`; +} + +function timezoneLabel(item: TaskSchedule) { + return item.timezone_mode === "beijing" ? "中国北京时间" : `系统时区${item.timezone ? `(${item.timezone})` : ""}`; +} + +function nextWindowLabel(value?: string, timezone?: string) { + if (!value) return "—"; + try { + const timeZone = timezone && timezone !== "Local" ? timezone : undefined; + return new Intl.DateTimeFormat("zh-CN", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hour12: false, + }).format(new Date(value)); + } catch { + return formatTime(value); + } +} + +function scheduleErrorText(value?: string) { + if (!value) return ""; + return value + .replaceAll("end_date", "截止日期") + .replaceAll("run_date", "开始日期") + .replaceAll("start_time", "开始时间") + .replaceAll("end_time", "结束时间") + .replaceAll("weekdays", "星期") + .replaceAll("timezone_mode", "时区") + .replaceAll("schedule_type", "规则"); +} + function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" { if (status === "running") return "default"; if (status === "error") return "destructive"; @@ -321,80 +388,104 @@ export default function SchedulesPage() { ); } else { scheduleContent = ( -
+
{items.map((item) => ( - - -
+
+
+
- {item.name} - - {item.schedule_type === "once" - ? item.run_date - : (item.weekdays ?? []).map((d) => weekdays[d - 1]).join("、")} - +
{item.name}
+
计划 #{item.id}
- {statusText(item)} + + {statusText(item)} +
- - -
- {item.start_time.slice(0, 5)} - {item.end_time ? ` - ${item.end_time.slice(0, 5)}` : " 起持续运行"} ·{" "} - {item.timezone_mode === "beijing" ? "北京时间" : item.timezone} +
{timezoneLabel(item)}
+
+
+
+ 运行周期: + {periodLabel(item)} +
+
+ 时间窗口: + {windowLabel(item)} +
+
+ 绑定任务: + {item.task_ids.length + ? `${item.task_ids.length} 个(${item.task_ids.map((id) => `#${id}`).join("、")})` + : "无"} +
+
+
+
+ 下一次开始: + {nextWindowLabel(item.next_start, item.timezone)} +
+
+ 下一次结束: + {nextWindowLabel(item.next_end, item.timezone)} +
+
+ 运行次数: + {item.run_count ?? 0} 次{item.last_run_at ? ` · 最近 ${formatTime(item.last_run_at)}` : ""}
-
绑定任务:{item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"}
最近变化:{formatTime(item.last_transition_at)} - {item.last_error ? ` · ${item.last_error}` : ""} + {item.last_error ? ` · ${scheduleErrorText(item.last_error)}` : ""}
-
-
+
+ + {item.enabled ? ( + - {item.enabled ? ( - - ) : ( - - )} - - -
- - + )} + + +
+
))}
); diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 7e06354..45ebca5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -734,6 +734,8 @@ export interface TaskSchedule { last_error?: string; next_start?: string; next_end?: string; + run_count?: number; + last_run_at?: string; task_ids: number[]; history?: Array<{ id: number;