diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b9ef4..5f1b07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ ## [Unreleased] +## [0.3.17] - 2026-10-09 + +### 计划任务 + +- 新增日历式计划任务,支持按时间规则创建、编辑、启停和删除任务。 +- 任务列表支持查看计划状态与下一次运行时间,并补充计划任务管理页面。 + ## [0.3.16] - 2026-10-09 ### 访问控制 diff --git a/README.md b/README.md index 69d6e49..b5c1fa7 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,7 @@ docker compose up -d # 拉取 ruoji6/artex 镜像 + postgres 镜像已含常用工具(ripgrep/curl/vim/npm/nmap…);`./skills` 与 `./data` 以绑定挂载持久化。 -`.env` 中默认 `ARTEX_IMAGE=ruoji6/artex`,`ARTEX_TAG=latest` 使用最新镜像,也可固定为 `v0.3.16`(完整镜像地址 `ruoji6/artex:v0.3.16`)。镜像支持 `linux/amd64` 和 `linux/arm64`,Docker 会自动选择与宿主机匹配的架构,ARM64 Kali 无需自行编译。初次部署请修改 `POSTGRES_PASSWORD`;运行服务无需配置 Docker Hub 发布 Token。 +`.env` 中默认 `ARTEX_IMAGE=ruoji6/artex`,`ARTEX_TAG=latest` 使用最新镜像,也可固定为 `v0.3.17`(完整镜像地址 `ruoji6/artex:v0.3.17`)。镜像支持 `linux/amd64` 和 `linux/arm64`,Docker 会自动选择与宿主机匹配的架构,ARM64 Kali 无需自行编译。初次部署请修改 `POSTGRES_PASSWORD`;运行服务无需配置 Docker Hub 发布 Token。 远程 MCP 可在系统设置中选择 `http`(Streamable HTTP)或 `sse`(旧版 SSE)。 旧版 SSE 服务通常使用 `GET /sse` 建立事件流,再通过服务返回的 @@ -165,12 +165,12 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release ### 维护者:通过 GitHub Actions 发布在线更新 -发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.16`;后续版本必须高于用户正在运行的版本。 +发布工作流为 `.github/workflows/release.yml`。推送正式标签会触发构建,也可在 Actions → release → Run workflow 中输入已存在的标签重新执行失败的发布。标签必须是 `v主版本.次版本.修订号`,例如下例的 `v0.3.17`;后续版本必须高于用户正在运行的版本。 ```bash git push origin main -git tag v0.3.16 -git push origin v0.3.16 +git tag v0.3.17 +git push origin v0.3.17 ``` 工作流固定标签对应的提交,用 Node.js 22 导出前端,按照 `go.mod` 指定的 Go 版本编译内嵌前端的五个平台程序,并生成 ZIP 与 `SHA256SUMS`。AMD64 和 ARM64 镜像分别在 GitHub 原生 runner 上构建,连接临时 PostgreSQL 验证程序启动、版本和内嵌前端;两种架构都通过后发布多架构镜像的版本标签与 `latest`。镜像发布成功后上传完整 ZIP 和校验文件,最后公开 Release。已公开的同版本不允许覆盖,应发布新标签。 @@ -204,10 +204,10 @@ docker compose logs --tail=50 artex ```bash # 在下载目录运行;先按 SHA256SUMS 核对下载的 ZIP,再解压。 sha256sum --ignore-missing --check SHA256SUMS -unzip artex-0.3.16-linux-arm64.zip +unzip artex-0.3.17-linux-arm64.zip sudo docker stop artex-artex-1 sudo docker cp artex-artex-1:/app/artex ./artex-before-migration -sudo docker cp ./artex-0.3.16-linux-arm64/artex artex-artex-1:/app/artex +sudo docker cp ./artex-0.3.17-linux-arm64/artex artex-artex-1:/app/artex sudo docker start artex-artex-1 curl --fail http://127.0.0.1:8787/api/health ``` @@ -231,7 +231,7 @@ cd ARTEX ```bash cd ARTEX git pull # 更新 compose / 脚本(可选) -# 指定版本:在 .env 设 ARTEX_TAG=v0.3.16;不设则用 latest +# 指定版本:在 .env 设 ARTEX_TAG=v0.3.17;不设则用 latest docker compose pull artex docker compose up -d artex # 换新镜像重启 → 自动迁移 schema docker image prune -f # 清理旧镜像(可选) diff --git a/db/schedules.go b/db/schedules.go index 12dec12..b5e908f 100644 --- a/db/schedules.go +++ b/db/schedules.go @@ -18,6 +18,7 @@ type TaskSchedule struct { TimezoneMode string `json:"timezone_mode"` ScheduleType string `json:"schedule_type"` RunDate string `json:"run_date,omitempty"` + EndDate string `json:"end_date,omitempty"` Weekdays []int `json:"weekdays,omitempty"` StartTime string `json:"start_time"` EndTime string `json:"end_time,omitempty"` @@ -37,6 +38,7 @@ type TaskScheduleInput struct { TimezoneMode string ScheduleType string RunDate string + EndDate string Weekdays []int StartTime string EndTime string @@ -46,6 +48,7 @@ type TaskScheduleInput struct { const scheduleCols = `id,name,enabled,timezone_mode,schedule_type, COALESCE(to_char(run_date,'YYYY-MM-DD'),''),weekdays,to_char(start_time,'HH24:MI:SS'), +COALESCE(to_char(end_date,'YYYY-MM-DD'),''), COALESCE(to_char(end_time,'HH24:MI:SS'),''),start_immediately,status,last_window_key, last_transition_at,last_error,created_at,updated_at` @@ -61,7 +64,7 @@ func scanSchedule(sc interface{ Scan(...any) error }) (*TaskSchedule, error) { var s TaskSchedule var rawDays string if err := sc.Scan(&s.ID, &s.Name, &s.Enabled, &s.TimezoneMode, &s.ScheduleType, - &s.RunDate, &rawDays, &s.StartTime, &s.EndTime, &s.StartImmediately, + &s.RunDate, &rawDays, &s.StartTime, &s.EndDate, &s.EndTime, &s.StartImmediately, &s.Status, &s.LastWindowKey, &s.LastTransitionAt, &s.LastError, &s.CreatedAt, &s.UpdatedAt); err != nil { return nil, err @@ -82,42 +85,58 @@ 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("截止日期格式不正确") + } + if endDate.Before(startDate) { + return in, fmt.Errorf("截止日期不能早于开始日期") + } + if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" { + if in.EndTime <= in.StartTime { + return in, fmt.Errorf("同一天的结束时间必须晚于开始时间") + } + } } in.Weekdays = nil } else { 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 = "" + in.EndDate = "" } 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 } @@ -152,9 +171,9 @@ func createTaskScheduleTxCommit(tx *sql.Tx, in TaskScheduleInput) (*TaskSchedule } func insertTaskSchedule(tx *sql.Tx, in TaskScheduleInput) (*TaskSchedule, error) { - row := tx.QueryRow(`INSERT INTO task_schedules(name,enabled,timezone_mode,schedule_type,run_date,weekdays,start_time,end_time,start_immediately) -VALUES ($1,$2,$3,$4,NULLIF($5,'')::date,$6,$7::time,NULLIF($8,'')::time,$9) RETURNING `+scheduleCols, - in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately) + row := tx.QueryRow(`INSERT INTO task_schedules(name,enabled,timezone_mode,schedule_type,run_date,end_date,weekdays,start_time,end_time,start_immediately) +VALUES ($1,$2,$3,$4,NULLIF($5,'')::date,NULLIF($6,'')::date,$7,$8::time,NULLIF($9,'')::time,$10) RETURNING `+scheduleCols, + in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, in.EndDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately) s, err := scanSchedule(row) if err != nil { return nil, err @@ -188,8 +207,8 @@ func (d *DB) UpdateTaskSchedule(id int64, in TaskScheduleInput) (*TaskSchedule, return nil, err } defer tx.Rollback() - row := tx.QueryRow(`UPDATE task_schedules SET name=$2,enabled=$3,timezone_mode=$4,schedule_type=$5,run_date=NULLIF($6,'')::date,weekdays=$7,start_time=$8::time,end_time=NULLIF($9,'')::time,start_immediately=$10,status=CASE WHEN $3 THEN CASE WHEN status='completed' THEN 'waiting' ELSE status END ELSE 'paused' END,last_error='',last_window_key='' WHERE id=$1 RETURNING `+scheduleCols, - id, in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately) + row := tx.QueryRow(`UPDATE task_schedules SET name=$2,enabled=$3,timezone_mode=$4,schedule_type=$5,run_date=NULLIF($6,'')::date,end_date=NULLIF($7,'')::date,weekdays=$8,start_time=$9::time,end_time=NULLIF($10,'')::time,start_immediately=$11,status=CASE WHEN $3 THEN CASE WHEN status='completed' THEN 'waiting' ELSE status END ELSE 'paused' END,last_error='',last_window_key='' WHERE id=$1 RETURNING `+scheduleCols, + id, in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, in.EndDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately) s, err := scanSchedule(row) if err != nil { return nil, err @@ -292,6 +311,31 @@ 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 { + 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) @@ -315,9 +359,12 @@ func (d *DB) SetSchedulePaused(scheduleID, taskID int64, paused bool) error { return err } -func (d *DB) SchedulePausedTasks(taskID int64) (bool, error) { +// TaskPausedBySchedule reads the authoritative task-level pause origin. The +// scheduler writes this flag when it pauses a task, so callers must use it to +// distinguish a scheduled pause from a manual pause. +func (d *DB) TaskPausedBySchedule(taskID int64) (bool, error) { var paused bool - err := d.QueryRow(`SELECT EXISTS(SELECT 1 FROM task_schedule_tasks WHERE task_id=$1 AND paused_by_schedule)`, taskID).Scan(&paused) + err := d.QueryRow(`SELECT schedule_paused FROM tasks WHERE id=$1 AND deleted_at IS NULL`, taskID).Scan(&paused) return paused, err } diff --git a/db/schema.sql b/db/schema.sql index a578e01..de7a5e3 100644 --- a/db/schema.sql +++ b/db/schema.sql @@ -1039,6 +1039,7 @@ CREATE TABLE IF NOT EXISTS task_schedules ( schedule_type TEXT NOT NULL DEFAULT 'weekly' CHECK (schedule_type IN ('once','weekly')), run_date DATE, + end_date DATE, weekdays TEXT NOT NULL DEFAULT '', start_time TIME NOT NULL, end_time TIME, @@ -1055,6 +1056,7 @@ CREATE TABLE IF NOT EXISTS task_schedules ( CHECK ((schedule_type = 'once') OR weekdays <> '') ); CREATE INDEX IF NOT EXISTS idx_task_schedules_enabled ON task_schedules(enabled, id); +ALTER TABLE task_schedules ADD COLUMN IF NOT EXISTS end_date DATE; DROP TRIGGER IF EXISTS trg_task_schedules_upd ON task_schedules; CREATE TRIGGER trg_task_schedules_upd BEFORE UPDATE ON task_schedules FOR EACH ROW EXECUTE FUNCTION set_updated_at(); diff --git a/server/calendar_schedule.go b/server/calendar_schedule.go index 6b2b5d1..deefc75 100644 --- a/server/calendar_schedule.go +++ b/server/calendar_schedule.go @@ -56,12 +56,32 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow { if err != nil { return scheduleWindow{} } - start, end := windowBounds(day, s, loc) + var start time.Time + var end *time.Time + if s.EndDate != "" { + endDay, parseErr := time.ParseInLocation("2006-01-02", s.EndDate, loc) + if parseErr != nil { + return scheduleWindow{} + } + start = scheduleClock(day, s.StartTime, loc) + endClock := s.EndTime + if endClock == "" { + endClock = "24:00" + } + endValue := scheduleClock(endDay, endClock, loc) + end = &endValue + } else { + start, end = windowBounds(day, s, loc) + } active := !now.Before(start) && (end == nil || now.Before(*end)) complete := end != nil && !now.Before(*end) // An open-ended one-shot remains active after its start until manually // paused; there is no end boundary at which it could be considered missed. - w := scheduleWindow{active: active, complete: complete, key: "once:" + s.RunDate, nextEnd: end} + key := "once:" + s.RunDate + if s.EndDate != "" { + key += ":" + s.EndDate + } + w := scheduleWindow{active: active, complete: complete, key: key, nextEnd: end} if now.Before(start) { w.key = "" w.nextStart = &start diff --git a/server/calendar_schedule_test.go b/server/calendar_schedule_test.go index d51ba4d..c1c9c8a 100644 --- a/server/calendar_schedule_test.go +++ b/server/calendar_schedule_test.go @@ -11,22 +11,52 @@ func TestEvaluateScheduleWindowWeeklyCrossMidnight(t *testing.T) { s := &db.TaskSchedule{ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{1, 2, 3, 4, 5}, StartTime: "18:00:00", EndTime: "09:00:00"} // Tuesday 08:00 is still inside Monday's overnight window. w := evaluateScheduleWindow(s, time.Date(2026, 10, 6, 8, 0, 0, 0, time.FixedZone("CST", 8*3600))) - if !w.active { t.Fatal("expected overnight window to be active") } + if !w.active { + t.Fatal("expected overnight window to be active") + } // Saturday 10:00 is outside the work-week window. w = evaluateScheduleWindow(s, time.Date(2026, 10, 10, 10, 0, 0, 0, time.FixedZone("CST", 8*3600))) - if w.active { t.Fatal("expected weekend to be outside") } + if w.active { + t.Fatal("expected weekend to be outside") + } } func TestEvaluateScheduleWindowOnce(t *testing.T) { s := &db.TaskSchedule{ScheduleType: "once", TimezoneMode: "beijing", RunDate: "2026-10-09", StartTime: "18:00:00", EndTime: "23:00:00"} loc := time.FixedZone("CST", 8*3600) - if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 17, 59, 0, 0, loc)); w.active { t.Fatal("one-shot fired early") } - if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 19, 0, 0, 0, loc)); !w.active { t.Fatal("one-shot should be active") } - if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 0, 0, 0, 0, loc)); !w.complete { t.Fatal("one-shot should be complete") } + if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 17, 59, 0, 0, loc)); w.active { + t.Fatal("one-shot fired early") + } + if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 19, 0, 0, 0, loc)); !w.active { + t.Fatal("one-shot should be active") + } + if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 0, 0, 0, 0, loc)); !w.complete { + t.Fatal("one-shot should be complete") + } } func TestEvaluateScheduleWindowOpenEnded(t *testing.T) { s := &db.TaskSchedule{ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{6}, StartTime: "09:00:00"} loc := time.FixedZone("CST", 8*3600) - if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 10, 0, 0, 0, loc)); !w.active { t.Fatal("open-ended weekly schedule should stay active") } + if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 10, 0, 0, 0, loc)); !w.active { + t.Fatal("open-ended weekly schedule should stay active") + } +} + +func TestEvaluateScheduleWindowOnceDateRange(t *testing.T) { + s := &db.TaskSchedule{ + ScheduleType: "once", + TimezoneMode: "beijing", + RunDate: "2026-10-09", + EndDate: "2026-10-11", + StartTime: "18:00:00", + EndTime: "09:00:00", + } + loc := time.FixedZone("CST", 8*3600) + if w := evaluateScheduleWindow(s, time.Date(2026, 10, 10, 12, 0, 0, 0, loc)); !w.active { + t.Fatal("date-range one-shot should remain active between its dates") + } + if w := evaluateScheduleWindow(s, time.Date(2026, 10, 11, 9, 0, 0, 0, loc)); !w.complete || w.active { + t.Fatal("date-range one-shot should complete at its end date and time") + } } diff --git a/server/schedules.go b/server/schedules.go index 9f53587..aa6c972 100644 --- a/server/schedules.go +++ b/server/schedules.go @@ -18,6 +18,7 @@ type scheduleRequest struct { TimezoneMode string `json:"timezone_mode"` ScheduleType string `json:"schedule_type"` RunDate string `json:"run_date"` + EndDate string `json:"end_date"` Weekdays []int `json:"weekdays"` StartTime string `json:"start_time"` EndTime string `json:"end_time"` @@ -33,7 +34,7 @@ func (s *Server) scheduleInput(req scheduleRequest, existing *db.TaskSchedule) d enabled = existing.Enabled } return db.TaskScheduleInput{Name: req.Name, Enabled: enabled, TimezoneMode: req.TimezoneMode, ScheduleType: req.ScheduleType, - RunDate: req.RunDate, Weekdays: req.Weekdays, StartTime: req.StartTime, EndTime: req.EndTime, + RunDate: req.RunDate, EndDate: req.EndDate, Weekdays: req.Weekdays, StartTime: req.StartTime, EndTime: req.EndTime, StartImmediately: req.StartImmediately, TaskIDs: req.TaskIDs} } @@ -41,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" { @@ -62,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}) } @@ -70,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) @@ -83,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) } @@ -120,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) @@ -144,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 { @@ -157,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 { @@ -184,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) @@ -192,23 +199,67 @@ 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.runScheduleTasksNow(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) { + return s.startScheduleTasks(item, false) +} + +func (s *Server) runScheduleTasksNow(item *db.TaskSchedule) (int, []string) { + return s.startScheduleTasks(item, true) +} + +func (s *Server) startScheduleTasks(item *db.TaskSchedule, forceManualPause bool) (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.TaskPausedBySchedule(taskID) + if err != nil { + errors = append(errors, fmt.Sprintf("任务 #%d 状态读取失败", taskID)) + _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "状态读取失败") + continue + } + if !managed && !forceManualPause { + 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) + message := "立即运行" + if !managed && forceManualPause { + message = "立即运行(显式恢复人工暂停)" + } + _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, message) } + 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 49177e8..4e500e1 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -2,9 +2,21 @@ import * as React from "react"; -import { CalendarClock, Loader2, Pause, Play, Plus, Search, Trash2, Zap } from "lucide-react"; +import { + CalendarClock, + ChevronDown, + ChevronRight, + Loader2, + Pause, + Play, + Plus, + Search, + Trash2, + Zap, +} from "lucide-react"; import { toast } from "sonner"; +import { DateSelect } from "@/components/date-select"; import { TimeSelect } from "@/components/time-select"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -13,6 +25,7 @@ import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { api, type ScheduleInput } from "@/lib/api"; import type { Task, TaskSchedule } from "@/lib/types"; @@ -40,7 +53,7 @@ function statusText(s: TaskSchedule) { error: "错误", paused: "已暂停", } as Record - )[s.status] ?? s.status + )[s.status] ?? "未知状态" ); } @@ -48,10 +61,193 @@ function formatTime(value?: string) { return value ? new Date(value).toLocaleString("zh-CN") : "—"; } -function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" { - if (status === "running") return "default"; - if (status === "error") return "destructive"; - return "secondary"; +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 startLabel(item: TaskSchedule) { + return item.schedule_type === "once" + ? `${formatDateValue(item.run_date)} ${formatClock(item.start_time)}` + : formatClock(item.start_time); +} + +function endLabel(item: TaskSchedule) { + if (item.schedule_type === "once" && item.end_date) { + return `${formatDateValue(item.end_date)} ${item.end_time ? formatClock(item.end_time) : "24:00"}`; + } + if (!item.end_time) return "持续运行"; + const end = formatClock(item.end_time); + const start = formatClock(item.start_time); + return `${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", "规则"); +} + +type ScheduleHistoryEntry = NonNullable[number]; + +function historyActionText(action: string) { + return ( + ( + { + running: "窗口开始", + outside: "窗口结束", + completed: "计划完成", + waiting: "等待窗口", + paused: "计划暂停", + resume: "恢复任务", + pause: "暂停任务", + error: "执行错误", + } as Record + )[action] ?? "状态变化" + ); +} + +function historyEventClass(event: ScheduleHistoryEntry) { + if (!event.success) + return "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300"; + if (event.action === "running" || event.action === "resume") { + return "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300"; + } + if (event.action === "outside" || event.action === "pause" || event.action === "paused") { + return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300"; + } + return "border-slate-200 bg-slate-50 text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300"; +} + +function historyDotClass(event: ScheduleHistoryEntry) { + if (!event.success) return "bg-red-500"; + if (event.action === "running" || event.action === "resume") return "bg-emerald-500"; + if (event.action === "outside" || event.action === "pause" || event.action === "paused") return "bg-amber-500"; + return "bg-slate-400"; +} + +function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; loading: boolean }) { + if (loading) { + return ( +
+ + 加载运行时间线… +
+ ); + } + // The API returns newest first. Keep that order so the latest transition is + // visible immediately when a row is expanded. + const events = schedule?.history ?? []; + if (!events.length) { + return
暂无运行记录,计划进入第一个窗口后会显示时间线。
; + } + return ( +
+
+
运行时间线
+ 最近 {Math.min(events.length, 20)} 条记录 +
+
+ {events.slice(0, 20).map((event, index) => ( +
+ {index < Math.min(events.length, 20) - 1 ? ( +
+ ))} +
+
+ ); +} + +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({ @@ -145,9 +341,25 @@ function ScheduleForm({ {draft.schedule_type === "once" ? ( -
- - set({ run_date: e.target.value })} /> +
+
+ + set({ run_date: value })} + aria-label="开始日期" + /> +
+
+ + set({ end_date: value })} + optional + placeholder="不设置截止日期" + aria-label="截止日期,可选" + /> +
) : (
@@ -186,7 +398,7 @@ function ScheduleForm({

- 结束时间为空表示开始后持续运行;结束时间早于开始时间表示跨午夜窗口。 + 一次性计划可设置截止日期;截止日期为空时仅按结束时间控制。结束时间为空表示持续运行,结束时间早于开始时间表示跨午夜窗口。

(null); const [saving, setSaving] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [page, setPage] = React.useState(1); + const [pageSize, setPageSize] = React.useState("10"); + const [selectedIds, setSelectedIds] = React.useState([]); + const [expandedId, setExpandedId] = React.useState(null); + const [timelineById, setTimelineById] = React.useState>({}); + const [timelineLoadingId, setTimelineLoadingId] = React.useState(null); const load = React.useCallback(async () => { setLoading(true); try { @@ -245,6 +464,78 @@ export default function SchedulesPage() { React.useEffect(() => { void load(); }, [load]); + const filteredItems = React.useMemo(() => { + const normalized = query.trim().toLowerCase(); + if (!normalized) return items; + return items.filter((item) => + [item.name, String(item.id), periodLabel(item), windowLabel(item), ...item.task_ids.map(String)] + .join(" ") + .toLowerCase() + .includes(normalized), + ); + }, [items, query]); + const size = Number(pageSize); + const totalPages = Math.max(1, Math.ceil(filteredItems.length / size)); + const visibleItems = filteredItems.slice((page - 1) * size, page * size); + const visibleIds = visibleItems.map((item) => item.id); + const selectedVisibleCount = visibleIds.filter((id) => selectedIds.includes(id)).length; + const allVisibleSelected = visibleItems.length > 0 && selectedVisibleCount === visibleItems.length; + const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected; + let headerSelectionState: boolean | "indeterminate" = false; + if (allVisibleSelected) headerSelectionState = true; + else if (someVisibleSelected) headerSelectionState = "indeterminate"; + React.useEffect(() => { + setPage((current) => Math.min(current, totalPages)); + }, [totalPages]); + const updateQuery = (value: string) => { + setQuery(value); + setPage(1); + }; + const updatePageSize = (value: string) => { + setPageSize(value); + setPage(1); + }; + const toggleSelected = (id: number, checked: boolean) => { + setSelectedIds((current) => { + if (!checked) return current.filter((selected) => selected !== id); + return current.includes(id) ? current : [...current, id]; + }); + }; + const toggleAllVisible = (checked: boolean) => { + setSelectedIds((current) => { + if (checked) return Array.from(new Set([...current, ...visibleIds])); + return current.filter((id) => !visibleIds.includes(id)); + }); + }; + const refreshTimeline = async (id: number) => { + setTimelineLoadingId(id); + try { + const detail = await api.schedule(id); + setTimelineById((current) => ({ ...current, [id]: detail })); + } catch (e) { + toast.error((e as Error).message); + } finally { + setTimelineLoadingId(null); + } + }; + const invalidateTimeline = async (id: number) => { + setTimelineById((current) => { + if (!current[id]) return current; + const next = { ...current }; + delete next[id]; + return next; + }); + if (expandedId === id) await refreshTimeline(id); + }; + const toggleTimeline = async (id: number) => { + if (expandedId === id) { + setExpandedId(null); + return; + } + setExpandedId(id); + if (timelineById[id]) return; + await refreshTimeline(id); + }; const save = async (value: ScheduleInput) => { if ( !value.name.trim() || @@ -271,6 +562,12 @@ export default function SchedulesPage() { if (!window.confirm("确定删除这个计划吗?")) return; try { await api.deleteSchedule(id); + setTimelineById((current) => { + const next = { ...current }; + delete next[id]; + return next; + }); + if (expandedId === id) setExpandedId(null); await load(); } catch (e) { toast.error((e as Error).message); @@ -280,12 +577,63 @@ 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} 个任务未启动:${result.errors.join(";")}`, + ); + } else { + toast.success(`已立即运行 ${result.started} 个任务`); + } + } + await invalidateTimeline(id); await load(); } catch (e) { toast.error((e as Error).message); } }; + const bulkRun = async () => { + const ids = [...selectedIds]; + if (!ids.length) return; + let started = 0; + const errors: string[] = []; + const results = await Promise.allSettled(ids.map((id) => api.runScheduleNow(id))); + results.forEach((result, index) => { + if (result.status === "rejected") { + errors.push(`计划 #${ids[index]}:${(result.reason as Error).message}`); + return; + } + started += result.value.started; + if (result.value.errors?.length) errors.push(`计划 #${ids[index]}:${result.value.errors.join(";")}`); + }); + if (errors.length) { + toast.warning(`已启动 ${started} 个任务,但有 ${errors.length} 项未完成:${errors.join(";")}`); + } else { + toast.success(`已立即运行 ${started} 个任务`); + } + setSelectedIds([]); + await Promise.all(ids.map((id) => invalidateTimeline(id))); + await load(); + }; + const bulkRemove = async () => { + const ids = [...selectedIds]; + if (!ids.length || !window.confirm(`确定删除选中的 ${ids.length} 个计划吗?`)) return; + const results = await Promise.allSettled(ids.map((id) => api.deleteSchedule(id))); + const failed = results.filter((result) => result.status === "rejected").length; + if (failed) toast.warning(`已删除 ${ids.length - failed} 个计划,${failed} 个计划删除失败`); + else toast.success(`已删除 ${ids.length} 个计划`); + setTimelineById((current) => { + const next = { ...current }; + ids.forEach((id) => { + delete next[id]; + }); + return next; + }); + if (expandedId && ids.includes(expandedId)) setExpandedId(null); + setSelectedIds([]); + await load(); + }; let scheduleContent: React.ReactNode; if (loading) { scheduleContent = ( @@ -304,80 +652,227 @@ 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("、")} - -
- {statusText(item)} -
-
- -
- {item.start_time.slice(0, 5)} - {item.end_time ? ` - ${item.end_time.slice(0, 5)}` : " 起持续运行"} ·{" "} - {item.timezone_mode === "beijing" ? "北京时间" : item.timezone} -
-
绑定任务:{item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"}
-
- 最近变化:{formatTime(item.last_transition_at)} - {item.last_error ? ` · ${item.last_error}` : ""} -
-
- - {item.enabled ? ( - - ) : ( - - )} - - -
-
+
+
+
+ + updateQuery(event.target.value)} + placeholder="检索计划名称、计划 ID 或任务 ID" + className="h-9 pl-8" + /> +
+
+ 每页 + + 共 {filteredItems.length} 条 +
+
+ {selectedIds.length ? ( +
+ 已选 {selectedIds.length} 个计划 +
+ + + +
+
+ ) : null} + {filteredItems.length === 0 ? ( + + 没有匹配的计划任务。 - ))} + ) : ( +
+ + + + + toggleAllVisible(checked === true)} + /> + + 计划名称 + 时间线 + 运行周期 + 开始时间 + 结束时间 + 运行次数 + 绑定任务 + 状态 + 操作 + + + + {visibleItems.map((item) => ( + + + + toggleSelected(item.id, checked === true)} + /> + + +
{item.name}
+
计划 #{item.id}
+
+ + + + {periodLabel(item)} + + {startLabel(item)} + {item.next_start ? ( +
+ 下次:{nextWindowLabel(item.next_start, item.timezone)} +
+ ) : null} +
+ + {endLabel(item)} + {item.next_end ? ( +
+ 下次:{nextWindowLabel(item.next_end, item.timezone)} +
+ ) : null} +
+ + {item.run_count ?? 0} + {item.last_run_at ? ( +
最近 {formatTime(item.last_run_at)}
+ ) : null} +
+ `#${id}`).join("、")} + > + {item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"} + + + + {statusText(item)} + +
{timezoneLabel(item)}
+
+ 最近:{formatTime(item.last_transition_at)} +
+ {item.last_error ? ( +
+ {scheduleErrorText(item.last_error)} +
+ ) : null} +
+ +
+ + {item.enabled ? ( + + ) : ( + + )} + + +
+
+
+ {expandedId === item.id ? ( + + + + + + ) : null} +
+ ))} +
+
+
+ )} +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
); } diff --git a/web/src/app/(main)/function/tasks/page.tsx b/web/src/app/(main)/function/tasks/page.tsx index cf8b8a4..a7cd194 100644 --- a/web/src/app/(main)/function/tasks/page.tsx +++ b/web/src/app/(main)/function/tasks/page.tsx @@ -45,10 +45,12 @@ import { import { toast } from "sonner"; import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor"; +import { DateSelect } from "@/components/date-select"; import { StatusBadge } from "@/components/status-badge"; import { TablePagination } from "@/components/table-pagination"; import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain"; import { TaskTemplateControls } from "@/components/task-template-controls"; +import { TimeSelect } from "@/components/time-select"; import { AlertDialog, AlertDialogAction, @@ -117,7 +119,6 @@ import { Switch } from "@/components/ui/switch"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; -import { TimeSelect } from "@/components/time-select"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { api, type ScheduleInput } from "@/lib/api"; import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; @@ -3101,6 +3102,7 @@ function CreateTaskSheet({ const [schedule, setSchedule] = React.useState({ name: "新建任务计划", schedule_type: "weekly", + end_date: "", weekdays: [6, 7], start_time: "18:00", end_time: "", @@ -3197,6 +3199,7 @@ function CreateTaskSheet({ setSchedule({ name: "新建任务计划", schedule_type: "weekly", + end_date: "", weekdays: [6, 7], start_time: "18:00", end_time: "", @@ -3321,14 +3324,26 @@ function CreateTaskSheet({
{schedule.schedule_type === "once" ? ( - - 执行日期 - setSchedule({ ...schedule, run_date: e.target.value })} - /> - +
+ + 开始日期 + setSchedule({ ...schedule, run_date: value })} + aria-label="开始日期" + /> + + + 截止日期(可选) + setSchedule({ ...schedule, end_date: value })} + optional + placeholder="不设置截止日期" + aria-label="截止日期,可选" + /> + +
) : ( 星期(1=周一,7=周日) diff --git a/web/src/components/date-select.tsx b/web/src/components/date-select.tsx new file mode 100644 index 0000000..2ef4006 --- /dev/null +++ b/web/src/components/date-select.tsx @@ -0,0 +1,88 @@ +"use client"; + +import * as React from "react"; + +import { format, parse } from "date-fns"; +import { zhCN } from "date-fns/locale/zh-CN"; +import { CalendarDays, ChevronDown, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; + +function parseDate(value?: string) { + if (!value) return undefined; + const parsed = parse(value, "yyyy-MM-dd", new Date()); + return Number.isNaN(parsed.getTime()) ? undefined : parsed; +} + +export function DateSelect({ + value, + onValueChange, + optional = false, + placeholder = "选择日期", + "aria-label": ariaLabel, +}: { + value?: string; + onValueChange: (value: string) => void; + optional?: boolean; + placeholder?: string; + "aria-label"?: string; +}) { + const [open, setOpen] = React.useState(false); + const selected = parseDate(value); + return ( + + + + + + { + onValueChange(date ? format(date, "yyyy-MM-dd") : ""); + setOpen(false); + }} + /> + {optional && selected && ( + + )} + + + ); +} diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index bef5a35..9a673f6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -252,6 +252,7 @@ export type ScheduleInput = { timezone_mode?: "beijing" | "system"; schedule_type: "once" | "weekly"; run_date?: string; + end_date?: string; weekdays?: number[]; start_time: string; end_time?: string; @@ -266,6 +267,7 @@ function schedulePayload(input: ScheduleInput) { timezone_mode: input.timezone_mode ?? "beijing", schedule_type: input.schedule_type, run_date: input.run_date ?? "", + end_date: input.end_date ?? "", weekdays: input.weekdays ?? [], start_time: input.start_time, end_time: input.end_time ?? "", @@ -326,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 }), diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4c2c9ed..45ebca5 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -723,6 +723,7 @@ export interface TaskSchedule { timezone?: string; schedule_type: "once" | "weekly"; run_date?: string; + end_date?: string; weekdays: number[]; start_time: string; end_time?: string; @@ -733,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;