From 98321d826265b317a6da7d71ecb0fb6f7ed1271a Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:50:05 +0800 Subject: [PATCH 01/10] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=97=A5=E6=9C=9F=E8=8C=83=E5=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 7 ++ README.md | 14 +-- db/schedules.go | 31 +++++-- db/schema.sql | 2 + server/calendar_schedule.go | 24 ++++- server/calendar_schedule_test.go | 42 +++++++-- server/schedules.go | 3 +- .../app/(main)/function/schedules/page.tsx | 26 +++++- web/src/app/(main)/function/tasks/page.tsx | 33 +++++-- web/src/components/date-select.tsx | 88 +++++++++++++++++++ web/src/lib/api.ts | 2 + web/src/lib/types.ts | 1 + 12 files changed, 238 insertions(+), 35 deletions(-) create mode 100644 web/src/components/date-select.tsx 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..83e5235 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 @@ -99,6 +102,21 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) { if _, err := time.Parse("2006-01-02", in.RunDate); err != nil { return in, fmt.Errorf("run_date 必须为 YYYY-MM-DD") } + 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") + } + if endDate.Before(startDate) { + return in, fmt.Errorf("end_date 不能早于 run_date") + } + if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" { + if in.EndTime <= in.StartTime { + return in, fmt.Errorf("同一天的 end_time 必须晚于 start_time") + } + } + } in.Weekdays = nil } else { seen := map[int]bool{} @@ -113,6 +131,7 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) { } sort.Ints(in.Weekdays) in.RunDate = "" + in.EndDate = "" } seenTasks := map[int64]bool{} for _, id := range in.TaskIDs { @@ -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 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..573ee6d 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} } diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index 49177e8..bc62628 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -5,6 +5,7 @@ import * as React from "react"; import { CalendarClock, 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"; @@ -145,9 +146,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 +203,7 @@ function ScheduleForm({

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

({ 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..4d2aad4 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 ?? "", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4c2c9ed..7e06354 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; From 30db68a86a319c9cd61cc56cbae809d730566dc1 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:30:51 +0800 Subject: [PATCH 02/10] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E5=88=97=E8=A1=A8=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schedules.go | 42 +++- server/schedules.go | 22 +- .../app/(main)/function/schedules/page.tsx | 221 ++++++++++++------ web/src/lib/types.ts | 2 + 4 files changed, 203 insertions(+), 84 deletions(-) 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; From aed3062b4d110fdc3830a606a9a15c37126dd543 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:38:22 +0800 Subject: [PATCH 03/10] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=A3=80=E7=B4=A2=E5=88=86=E9=A1=B5=E5=92=8C?= =?UTF-8?q?=E6=97=B6=E9=97=B4=E7=BA=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/function/schedules/page.tsx | 396 +++++++++++++----- 1 file changed, 297 insertions(+), 99 deletions(-) diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index 31fba70..e0f2fd0 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -2,7 +2,18 @@ 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"; @@ -116,6 +127,83 @@ function scheduleErrorText(value?: string) { .replaceAll("schedule_type", "规则"); } +type ScheduleHistoryEntry = NonNullable[number]; + +function historyActionText(action: string) { + return ( + ( + { + running: "窗口开始", + outside: "窗口结束", + completed: "计划完成", + waiting: "等待窗口", + paused: "计划暂停", + resume: "恢复任务", + pause: "暂停任务", + error: "执行错误", + } as Record + )[action] ?? "状态变化" + ); +} + +function timelinePosition(event: ScheduleHistoryEntry, index: number, events: ScheduleHistoryEntry[]) { + if (events.length <= 1) return 50; + const timestamps = events.map((item) => Date.parse(item.created_at)).filter(Number.isFinite); + const time = Date.parse(event.created_at); + const min = Math.min(...timestamps); + const max = Math.max(...timestamps); + if (!Number.isFinite(time) || !Number.isFinite(min) || max <= min) return (index / (events.length - 1)) * 100; + return ((time - min) / (max - min)) * 100; +} + +function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; loading: boolean }) { + if (loading) { + return ( +
+ + 加载运行时间线… +
+ ); + } + const events = [...(schedule?.history ?? [])].reverse(); + if (!events.length) { + return
暂无运行记录,计划进入第一个窗口后会显示时间线。
; + } + return ( +
+
运行时间线
+
+
+ {events.map((event, index) => ( +
+
+
+ {historyActionText(event.action)} +
+
+ ))} +
+
+ {events.slice(-20).map((event) => ( +
+ {historyActionText(event.action)} + {formatTime(event.created_at)} + {event.task_id ? 任务 #{event.task_id} : null} + {event.message ? ( + {event.message} + ) : null} +
+ ))} +
+
+ ); +} + function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" { if (status === "running") return "default"; if (status === "error") return "destructive"; @@ -314,6 +402,12 @@ export default function SchedulesPage() { const [loading, setLoading] = React.useState(true); const [editing, setEditing] = React.useState<{ id?: number; value: ScheduleInput } | null>(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 [expandedId, setExpandedId] = React.useState(null); + const [timelineById, setTimelineById] = React.useState>({}); + const [timelineLoadingId, setTimelineLoadingId] = React.useState(null); const load = React.useCallback(async () => { setLoading(true); try { @@ -329,6 +423,47 @@ 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); + 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 toggleTimeline = async (id: number) => { + if (expandedId === id) { + setExpandedId(null); + return; + } + setExpandedId(id); + if (timelineById[id]) return; + 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 save = async (value: ScheduleInput) => { if ( !value.name.trim() || @@ -388,105 +523,168 @@ export default function SchedulesPage() { ); } else { scheduleContent = ( -
- {items.map((item) => ( -
-
-
-
-
{item.name}
-
计划 #{item.id}
-
- - {statusText(item)} - -
-
{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)}` : ""} -
-
- 最近变化:{formatTime(item.last_transition_at)} - {item.last_error ? ` · ${scheduleErrorText(item.last_error)}` : ""} -
-
-
- - {item.enabled ? ( - - ) : ( - - )} - - -
+
+
+
+ + updateQuery(event.target.value)} + placeholder="检索计划名称、计划 ID 或任务 ID" + className="h-9 pl-8" + />
- ))} +
+ 每页 + + 共 {filteredItems.length} 条 +
+
+ {filteredItems.length === 0 ? ( + + 没有匹配的计划任务。 + + ) : ( +
+ {visibleItems.map((item) => ( + +
+
+
+
+
{item.name}
+
计划 #{item.id}
+
+ + {statusText(item)} + +
+
{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)}` : ""} +
+
+ 最近变化:{formatTime(item.last_transition_at)} + {item.last_error ? ` · ${scheduleErrorText(item.last_error)}` : ""} +
+
+
+ + + {item.enabled ? ( + + ) : ( + + )} + + +
+
+ {expandedId === item.id ? ( +
+ +
+ ) : null} +
+ ))} +
+ )} +
+ + 第 {page} / {totalPages} 页 + +
+ + +
+
); } From e52fe671718f7545f267620127992614176270de Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:47:14 +0800 Subject: [PATCH 04/10] =?UTF-8?q?=E5=B0=86=E8=AE=A1=E5=88=92=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E6=94=B9=E4=B8=BA=E5=AD=97=E6=AE=B5=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/function/schedules/page.tsx | 256 ++++++++++-------- 1 file changed, 147 insertions(+), 109 deletions(-) diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index e0f2fd0..28ab74a 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -25,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"; @@ -93,6 +94,21 @@ function windowLabel(item: TaskSchedule) { 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.end_time) return "持续运行"; + const end = formatClock(item.end_time); + if (item.schedule_type === "once" && item.end_date) + return `${formatDateValue(item.end_date)} ${end === "未设置" ? "24:00" : end}`; + const start = formatClock(item.start_time); + return `${end}${end <= start ? "(次日)" : ""}`; +} + function timezoneLabel(item: TaskSchedule) { return item.timezone_mode === "beijing" ? "中国北京时间" : `系统时区${item.timezone ? `(${item.timezone})` : ""}`; } @@ -554,117 +570,139 @@ export default function SchedulesPage() { 没有匹配的计划任务。 ) : ( -
- {visibleItems.map((item) => ( - -
-
-
-
+
+ + + + 计划名称 + 运行周期 + 开始时间 + 结束时间 + 运行次数 + 绑定任务 + 状态 + 操作 + + + + {visibleItems.map((item) => ( + + +
{item.name}
计划 #{item.id}
- - - {statusText(item)} - - -
{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)}` : ""} -
-
- 最近变化:{formatTime(item.last_transition_at)} - {item.last_error ? ` · ${scheduleErrorText(item.last_error)}` : ""} -
-
-
- - - {item.enabled ? ( - - ) : ( - - )} - - -
- - {expandedId === item.id ? ( -
- -
- ) : null} -
- ))} + + {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} + + ))} +
+
)}
From a5cfec025f6e34b03a9936b474d2133226100150 Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Sat, 10 Oct 2026 08:39:55 +0800 Subject: [PATCH 05/10] =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1=E6=97=B6=E9=97=B4=E7=BA=BF?= =?UTF-8?q?=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/(main)/function/schedules/page.tsx | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index 28ab74a..fb828fb 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -575,6 +575,7 @@ export default function SchedulesPage() { 计划名称 + 时间线 运行周期 开始时间 结束时间 @@ -592,6 +593,16 @@ export default function SchedulesPage() {
{item.name}
计划 #{item.id}
+ + + {periodLabel(item)} {startLabel(item)} @@ -638,14 +649,6 @@ export default function SchedulesPage() {
- + + +
+
+ ) : null} {filteredItems.length === 0 ? ( 没有匹配的计划任务。 @@ -606,6 +676,13 @@ export default function SchedulesPage() { + + toggleAllVisible(checked === true)} + /> + 计划名称 时间线 运行周期 @@ -621,6 +698,13 @@ export default function SchedulesPage() { {visibleItems.map((item) => ( + + toggleSelected(item.id, checked === true)} + /> +
{item.name}
计划 #{item.id}
@@ -731,7 +815,7 @@ export default function SchedulesPage() {
{expandedId === item.id ? ( - + From 0af7132ab74103ea087aacbcf028965041a63f1f Mon Sep 17 00:00:00 2001 From: ji ruo <79234113+RuoJi6@users.noreply.github.com> Date: Sat, 10 Oct 2026 09:16:28 +0800 Subject: [PATCH 10/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A1=E5=88=92?= =?UTF-8?q?=E6=9A=82=E5=81=9C=E6=9D=A5=E6=BA=90=E5=92=8C=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=BA=BF=E5=88=B7=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- db/schedules.go | 7 ++- server/schedules.go | 2 +- .../app/(main)/function/schedules/page.tsx | 47 +++++++++++++++---- 3 files changed, 44 insertions(+), 12 deletions(-) diff --git a/db/schedules.go b/db/schedules.go index 0183bcb..b5e908f 100644 --- a/db/schedules.go +++ b/db/schedules.go @@ -359,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/server/schedules.go b/server/schedules.go index 34ac5d2..aa6c972 100644 --- a/server/schedules.go +++ b/server/schedules.go @@ -234,7 +234,7 @@ func (s *Server) startScheduleTasks(item *db.TaskSchedule, forceManualPause bool started++ continue } - managed, err := s.m.pg.SchedulePausedTasks(taskID) + 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, "状态读取失败") diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx index 49ff3c9..4e500e1 100644 --- a/web/src/app/(main)/function/schedules/page.tsx +++ b/web/src/app/(main)/function/schedules/page.tsx @@ -101,10 +101,11 @@ function startLabel(item: TaskSchedule) { } 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); - if (item.schedule_type === "once" && item.end_date) - return `${formatDateValue(item.end_date)} ${end === "未设置" ? "24:00" : end}`; const start = formatClock(item.start_time); return `${end}${end <= start ? "(次日)" : ""}`; } @@ -506,13 +507,7 @@ export default function SchedulesPage() { return current.filter((id) => !visibleIds.includes(id)); }); }; - const toggleTimeline = async (id: number) => { - if (expandedId === id) { - setExpandedId(null); - return; - } - setExpandedId(id); - if (timelineById[id]) return; + const refreshTimeline = async (id: number) => { setTimelineLoadingId(id); try { const detail = await api.schedule(id); @@ -523,6 +518,24 @@ export default function SchedulesPage() { 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() || @@ -549,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); @@ -568,6 +587,7 @@ export default function SchedulesPage() { toast.success(`已立即运行 ${result.started} 个任务`); } } + await invalidateTimeline(id); await load(); } catch (e) { toast.error((e as Error).message); @@ -593,6 +613,7 @@ export default function SchedulesPage() { toast.success(`已立即运行 ${started} 个任务`); } setSelectedIds([]); + await Promise.all(ids.map((id) => invalidateTimeline(id))); await load(); }; const bulkRemove = async () => { @@ -602,6 +623,14 @@ export default function SchedulesPage() { 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(); };