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] =?UTF-8?q?=E6=94=AF=E6=8C=81=E8=AE=A1=E5=88=92=E4=BB=BB?= =?UTF-8?q?=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;