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 (
+
+
+
+
+
+ {selected ? (
+ format(selected, "yyyy年M月d日", { locale: zhCN })
+ ) : (
+ {placeholder}
+ )}
+
+
+
+
+
+
+
+ {
+ onValueChange(date ? format(date, "yyyy-MM-dd") : "");
+ setOpen(false);
+ }}
+ />
+ {optional && selected && (
+ {
+ onValueChange("");
+ setOpen(false);
+ }}
+ >
+
+ 清除日期
+
+ )}
+
+
+ );
+}
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)}` : ""}
-
-
- setEditing({
- id: item.id,
- value: {
- name: item.name,
- enabled: item.enabled,
- timezone_mode: item.timezone_mode,
- schedule_type: item.schedule_type,
- run_date: item.run_date,
- end_date: item.end_date,
- weekdays: item.weekdays,
- start_time: item.start_time.slice(0, 5),
- end_time: item.end_time?.slice(0, 5),
- start_immediately: item.start_immediately,
- task_ids: item.task_ids,
- },
- })
- }
- >
- 编辑
+
+
+
+ setEditing({
+ id: item.id,
+ value: {
+ name: item.name,
+ enabled: item.enabled,
+ timezone_mode: item.timezone_mode,
+ schedule_type: item.schedule_type,
+ run_date: item.run_date,
+ end_date: item.end_date,
+ weekdays: item.weekdays,
+ start_time: item.start_time.slice(0, 5),
+ end_time: item.end_time?.slice(0, 5),
+ start_immediately: item.start_immediately,
+ task_ids: item.task_ids,
+ },
+ })
+ }
+ >
+ 编辑
+
+ {item.enabled ? (
+
void action(item.id, "pause")}>
+
+ 暂停
- {item.enabled ? (
-
void action(item.id, "pause")}>
-
- 暂停
-
- ) : (
-
void action(item.id, "resume")}>
-
- 启用
-
- )}
-
void action(item.id, "run")}>
-
- 立即运行
+ ) : (
+ void action(item.id, "resume")}>
+
+ 启用
- void remove(item.id)}>
-
- 删除
-
-
-
-
+ )}
+
void action(item.id, "run")}>
+
+ 立即运行
+
+
void remove(item.id)}>
+
+ 删除
+
+
+
))}
);
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)}` : ""}
-
-
-
-
- setEditing({
- id: item.id,
- value: {
- name: item.name,
- enabled: item.enabled,
- timezone_mode: item.timezone_mode,
- schedule_type: item.schedule_type,
- run_date: item.run_date,
- end_date: item.end_date,
- weekdays: item.weekdays,
- start_time: item.start_time.slice(0, 5),
- end_time: item.end_time?.slice(0, 5),
- start_immediately: item.start_immediately,
- task_ids: item.task_ids,
- },
- })
- }
- >
- 编辑
-
- {item.enabled ? (
-
void action(item.id, "pause")}>
-
- 暂停
-
- ) : (
-
void action(item.id, "resume")}>
-
- 启用
-
- )}
-
void action(item.id, "run")}>
-
- 立即运行
-
-
void remove(item.id)}>
-
- 删除
-
-
+
+
+
+
+ updateQuery(event.target.value)}
+ placeholder="检索计划名称、计划 ID 或任务 ID"
+ className="h-9 pl-8"
+ />
- ))}
+
+ 每页
+
+
+
+
+
+ 10 条
+ 20 条
+ 50 条
+
+
+ 共 {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)}` : ""}
+
+
+
+
void toggleTimeline(item.id)}>
+ {expandedId === item.id ? (
+
+ ) : (
+
+ )}
+ {expandedId === item.id ? "收起时间线" : "查看时间线"}
+
+
+ setEditing({
+ id: item.id,
+ value: {
+ name: item.name,
+ enabled: item.enabled,
+ timezone_mode: item.timezone_mode,
+ schedule_type: item.schedule_type,
+ run_date: item.run_date,
+ end_date: item.end_date,
+ weekdays: item.weekdays,
+ start_time: item.start_time.slice(0, 5),
+ end_time: item.end_time?.slice(0, 5),
+ start_immediately: item.start_immediately,
+ task_ids: item.task_ids,
+ },
+ })
+ }
+ >
+ 编辑
+
+ {item.enabled ? (
+
void action(item.id, "pause")}>
+
+ 暂停
+
+ ) : (
+
void action(item.id, "resume")}>
+
+ 启用
+
+ )}
+
void action(item.id, "run")}>
+
+ 立即运行
+
+
void remove(item.id)}>
+
+ 删除
+
+
+
+ {expandedId === item.id ? (
+
+
+
+ ) : null}
+
+ ))}
+
+ )}
+
+
+ 第 {page} / {totalPages} 页
+
+
+ setPage((current) => current - 1)}>
+ 上一页
+
+ = totalPages}
+ onClick={() => setPage((current) => current + 1)}
+ >
+ 下一页
+
+
+
);
}
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)}` : ""}
-
-
-
-
void toggleTimeline(item.id)}>
- {expandedId === item.id ? (
-
- ) : (
-
- )}
- {expandedId === item.id ? "收起时间线" : "查看时间线"}
-
-
- setEditing({
- id: item.id,
- value: {
- name: item.name,
- enabled: item.enabled,
- timezone_mode: item.timezone_mode,
- schedule_type: item.schedule_type,
- run_date: item.run_date,
- end_date: item.end_date,
- weekdays: item.weekdays,
- start_time: item.start_time.slice(0, 5),
- end_time: item.end_time?.slice(0, 5),
- start_immediately: item.start_immediately,
- task_ids: item.task_ids,
- },
- })
- }
- >
- 编辑
-
- {item.enabled ? (
-
void action(item.id, "pause")}>
-
- 暂停
-
- ) : (
-
void action(item.id, "resume")}>
-
- 启用
-
- )}
-
void action(item.id, "run")}>
-
- 立即运行
-
-
void remove(item.id)}>
-
- 删除
-
-
-
- {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}
+
+
+
+
void toggleTimeline(item.id)}>
+ {expandedId === item.id ? (
+
+ ) : (
+
+ )}
+ {expandedId === item.id ? "收起时间线" : "查看时间线"}
+
+
+ setEditing({
+ id: item.id,
+ value: {
+ name: item.name,
+ enabled: item.enabled,
+ timezone_mode: item.timezone_mode,
+ schedule_type: item.schedule_type,
+ run_date: item.run_date,
+ end_date: item.end_date,
+ weekdays: item.weekdays,
+ start_time: item.start_time.slice(0, 5),
+ end_time: item.end_time?.slice(0, 5),
+ start_immediately: item.start_immediately,
+ task_ids: item.task_ids,
+ },
+ })
+ }
+ >
+ 编辑
+
+ {item.enabled ? (
+
void action(item.id, "pause")}>
+
+ 暂停
+
+ ) : (
+
void action(item.id, "resume")}>
+
+ 启用
+
+ )}
+
void action(item.id, "run")}>
+
+ 立即运行
+
+
void remove(item.id)}>
+
+ 删除
+
+
+
+
+ {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}
+
+ void toggleTimeline(item.id)}>
+ {expandedId === item.id ? (
+
+ ) : (
+
+ )}
+ {expandedId === item.id ? "收起" : "查看"}
+
+
{periodLabel(item)}
{startLabel(item)}
@@ -638,14 +649,6 @@ export default function SchedulesPage() {
-
void toggleTimeline(item.id)}>
- {expandedId === item.id ? (
-
- ) : (
-
- )}
- {expandedId === item.id ? "收起时间线" : "查看时间线"}
-
{expandedId === item.id ? (
-
+
From 8067f3286b8b4207fa27087cf16ca22abbd76497 Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Sat, 10 Oct 2026 08:45:06 +0800
Subject: [PATCH 06/10] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=AE=A1=E5=88=92?=
=?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=AB=8B=E5=8D=B3=E8=BF=90=E8=A1=8C=E5=B9=B6?=
=?UTF-8?q?=E5=8C=BA=E5=88=86=E7=8A=B6=E6=80=81=E9=A2=9C=E8=89=B2?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
db/schedules.go | 5 +++
server/schedules.go | 42 ++++++++++++++++---
.../app/(main)/function/schedules/page.tsx | 31 +++++++++++---
web/src/lib/api.ts | 3 +-
4 files changed, 69 insertions(+), 12 deletions(-)
diff --git a/db/schedules.go b/db/schedules.go
index ce1bb1b..0183bcb 100644
--- a/db/schedules.go
+++ b/db/schedules.go
@@ -311,6 +311,11 @@ func (d *DB) TouchTaskSchedule(id int64, status, windowKey, lastError string) er
return err
}
+func (d *DB) MarkTaskScheduleManual(id int64) error {
+ _, err := d.Exec(`UPDATE task_schedules SET status='running',last_window_key='manual',last_transition_at=now(),last_error='' WHERE id=$1`, id)
+ return err
+}
+
// ScheduleRunStats summarizes completed schedule window entries for list views.
// The scheduler records one "running" history entry whenever a new window opens.
type ScheduleRunStats struct {
diff --git a/server/schedules.go b/server/schedules.go
index f6a17b1..67b83fa 100644
--- a/server/schedules.go
+++ b/server/schedules.go
@@ -199,23 +199,55 @@ func (s *Server) runScheduleNow(w http.ResponseWriter, r *http.Request) {
writeErr(w, 404, "计划不存在")
return
}
- s.runScheduleTasks(item)
- writeJSON(w, 200, map[string]any{"ok": true})
+ if err := s.m.pg.MarkTaskScheduleManual(id); err != nil {
+ writeErr(w, 500, "无法启动计划任务")
+ return
+ }
+ _ = s.m.pg.RecordScheduleHistory(id, 0, "running", true, "手动立即运行")
+ started, errors := s.runScheduleTasks(item)
+ writeJSON(w, 200, map[string]any{"ok": true, "started": started, "errors": errors})
}
-func (s *Server) runScheduleTasks(item *db.TaskSchedule) {
+func (s *Server) runScheduleTasks(item *db.TaskSchedule) (int, []string) {
+ started := 0
+ errors := []string{}
for _, taskID := range item.TaskIDs {
t, ok := s.m.Task(strconv.FormatInt(taskID, 10))
if !ok {
+ errors = append(errors, fmt.Sprintf("任务 #%d 未找到", taskID))
continue
}
state := t.lifecycleSnapshot()
- if db.IsTerminal(state.Status) || !state.Paused {
+ if db.IsTerminal(state.Status) {
+ errors = append(errors, fmt.Sprintf("任务 #%d 已结束,无法立即运行", taskID))
continue
}
- _, _ = s.applyTaskControlWithCause(t, "resume", agent.AbortPausedByOrchestrator)
+ if !state.Paused {
+ started++
+ continue
+ }
+ managed, err := s.m.pg.SchedulePausedTasks(taskID)
+ if err != nil {
+ errors = append(errors, fmt.Sprintf("任务 #%d 状态读取失败", taskID))
+ _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "状态读取失败")
+ continue
+ }
+ if !managed {
+ errors = append(errors, fmt.Sprintf("任务 #%d 由人工暂停,计划不能强制恢复", taskID))
+ _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "人工暂停优先")
+ continue
+ }
+ _, err = s.applyTaskControlWithCause(t, "resume", agent.AbortPausedByOrchestrator)
+ if err != nil {
+ errors = append(errors, fmt.Sprintf("任务 #%d:%s", taskID, err.Error()))
+ _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, err.Error())
+ continue
+ }
+ started++
_ = s.m.pg.SetSchedulePaused(item.ID, taskID, false)
+ _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, "立即运行")
}
+ return started, errors
}
// scheduleRequestFromTask is used by the task creation handler without making
diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx
index fb828fb..ddabb26 100644
--- a/web/src/app/(main)/function/schedules/page.tsx
+++ b/web/src/app/(main)/function/schedules/page.tsx
@@ -220,10 +220,20 @@ function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; load
);
}
-function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" {
- if (status === "running") return "default";
- if (status === "error") return "destructive";
- return "secondary";
+function statusClass(status: TaskSchedule["status"]) {
+ return (
+ {
+ waiting: "border-sky-200 bg-sky-50 text-sky-700 dark:border-sky-900 dark:bg-sky-950/40 dark:text-sky-300",
+ running:
+ "border-emerald-200 bg-emerald-50 text-emerald-700 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-300",
+ outside:
+ "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-300",
+ completed: "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900 dark:bg-blue-950/40 dark:text-blue-300",
+ error: "border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300",
+ paused:
+ "border-slate-200 bg-slate-100 text-slate-700 dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300",
+ } as Record
+ )[status];
}
function ScheduleForm({
@@ -515,7 +525,14 @@ export default function SchedulesPage() {
try {
if (type === "pause") await api.pauseSchedule(id);
else if (type === "resume") await api.resumeSchedule(id);
- else await api.runScheduleNow(id);
+ else {
+ const result = await api.runScheduleNow(id);
+ if (result.errors?.length) {
+ toast.warning(`已启动 ${result.started} 个任务,但有 ${result.errors.length} 个任务未启动`);
+ } else {
+ toast.success(`已立即运行 ${result.started} 个任务`);
+ }
+ }
await load();
} catch (e) {
toast.error((e as Error).message);
@@ -633,7 +650,9 @@ export default function SchedulesPage() {
{item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"}
- {statusText(item)}
+
+ {statusText(item)}
+
{timezoneLabel(item)}
最近:{formatTime(item.last_transition_at)}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
index 4d2aad4..9a673f6 100644
--- a/web/src/lib/api.ts
+++ b/web/src/lib/api.ts
@@ -328,7 +328,8 @@ export const api = {
deleteSchedule: (id: number) => del<{ ok: boolean }>(`/schedules/${id}`),
pauseSchedule: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/pause`, {}),
resumeSchedule: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/resume`, {}),
- runScheduleNow: (id: number) => post<{ ok: boolean }>(`/schedules/${id}/run-now`, {}),
+ runScheduleNow: (id: number) =>
+ post<{ ok: boolean; started: number; errors?: string[] }>(`/schedules/${id}/run-now`, {}),
taskCategories: () => get<{ categories: TaskCategory[] }>("/task-categories").then((r) => arr(r.categories)),
updateTask: (id: string, input: { name?: string; pinned?: boolean }) => patch(`/tasks/${id}`, input),
renameTask: (id: string, name: string) => patch(`/tasks/${id}`, { name }),
From 8a1c9debbb44e7763ad4ec124486067c0544bb6b Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Sat, 10 Oct 2026 08:50:15 +0800
Subject: [PATCH 07/10] =?UTF-8?q?=E8=AE=A9=E7=AB=8B=E5=8D=B3=E8=BF=90?=
=?UTF-8?q?=E8=A1=8C=E7=9B=B4=E6=8E=A5=E6=81=A2=E5=A4=8D=E4=BB=BB=E5=8A=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/schedules.go | 18 +++++++++++++++---
web/src/app/(main)/function/schedules/page.tsx | 4 +++-
2 files changed, 18 insertions(+), 4 deletions(-)
diff --git a/server/schedules.go b/server/schedules.go
index 67b83fa..34ac5d2 100644
--- a/server/schedules.go
+++ b/server/schedules.go
@@ -204,11 +204,19 @@ func (s *Server) runScheduleNow(w http.ResponseWriter, r *http.Request) {
return
}
_ = s.m.pg.RecordScheduleHistory(id, 0, "running", true, "手动立即运行")
- started, errors := s.runScheduleTasks(item)
+ started, errors := s.runScheduleTasksNow(item)
writeJSON(w, 200, map[string]any{"ok": true, "started": started, "errors": errors})
}
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 {
@@ -232,7 +240,7 @@ func (s *Server) runScheduleTasks(item *db.TaskSchedule) (int, []string) {
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "状态读取失败")
continue
}
- if !managed {
+ if !managed && !forceManualPause {
errors = append(errors, fmt.Sprintf("任务 #%d 由人工暂停,计划不能强制恢复", taskID))
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "人工暂停优先")
continue
@@ -245,7 +253,11 @@ func (s *Server) runScheduleTasks(item *db.TaskSchedule) (int, []string) {
}
started++
_ = s.m.pg.SetSchedulePaused(item.ID, taskID, false)
- _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, "立即运行")
+ message := "立即运行"
+ if !managed && forceManualPause {
+ message = "立即运行(显式恢复人工暂停)"
+ }
+ _ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, message)
}
return started, errors
}
diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx
index ddabb26..2d8c12e 100644
--- a/web/src/app/(main)/function/schedules/page.tsx
+++ b/web/src/app/(main)/function/schedules/page.tsx
@@ -528,7 +528,9 @@ export default function SchedulesPage() {
else {
const result = await api.runScheduleNow(id);
if (result.errors?.length) {
- toast.warning(`已启动 ${result.started} 个任务,但有 ${result.errors.length} 个任务未启动`);
+ toast.warning(
+ `已启动 ${result.started} 个任务,但有 ${result.errors.length} 个任务未启动:${result.errors.join(";")}`,
+ );
} else {
toast.success(`已立即运行 ${result.started} 个任务`);
}
From 96a10878ad9c1d161ab5ac608d94acb564d62fc0 Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Sat, 10 Oct 2026 08:56:13 +0800
Subject: [PATCH 08/10] =?UTF-8?q?=E9=87=8D=E6=96=B0=E8=AE=BE=E8=AE=A1?=
=?UTF-8?q?=E8=AE=A1=E5=88=92=E4=BB=BB=E5=8A=A1=E8=BF=90=E8=A1=8C=E6=97=B6?=
=?UTF-8?q?=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 | 79 +++++++++++--------
1 file changed, 46 insertions(+), 33 deletions(-)
diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx
index 2d8c12e..6b9a3ce 100644
--- a/web/src/app/(main)/function/schedules/page.tsx
+++ b/web/src/app/(main)/function/schedules/page.tsx
@@ -162,14 +162,23 @@ function historyActionText(action: string) {
);
}
-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 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 }) {
@@ -181,38 +190,42 @@ function ScheduleTimeline({ schedule, loading }: { schedule?: TaskSchedule; load
);
}
- const events = [...(schedule?.history ?? [])].reverse();
+ // 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 (
-
运行时间线
-
-
- {events.map((event, index) => (
-
-
-
- {historyActionText(event.action)}
-
-
- ))}
+
+
运行时间线
+
最近 {Math.min(events.length, 20)} 条记录
-
- {events.slice(-20).map((event) => (
-
-
{historyActionText(event.action)}
-
{formatTime(event.created_at)}
- {event.task_id ?
任务 #{event.task_id} : null}
- {event.message ? (
-
{event.message}
+
+ {events.slice(0, 20).map((event, index) => (
+
+ {index < Math.min(events.length, 20) - 1 ? (
+
) : null}
+
+
+
+
+ {historyActionText(event.action)}
+
+ {formatTime(event.created_at)}
+ {event.task_id ? `任务 #${event.task_id}` : "计划任务"}
+
+ {event.message ? (
+
+ {event.message}
+
+ ) : null}
+
))}
From 87e62fb904c4d2baee7f787772c3596befa4eea6 Mon Sep 17 00:00:00 2001
From: ji ruo <79234113+RuoJi6@users.noreply.github.com>
Date: Sat, 10 Oct 2026 09:01:21 +0800
Subject: [PATCH 09/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=89=B9=E9=87=8F=E9=80=89=E6=8B=A9=E6=93=8D?=
=?UTF-8?q?=E4=BD=9C?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../app/(main)/function/schedules/page.tsx | 86 ++++++++++++++++++-
1 file changed, 85 insertions(+), 1 deletion(-)
diff --git a/web/src/app/(main)/function/schedules/page.tsx b/web/src/app/(main)/function/schedules/page.tsx
index 6b9a3ce..49ff3c9 100644
--- a/web/src/app/(main)/function/schedules/page.tsx
+++ b/web/src/app/(main)/function/schedules/page.tsx
@@ -444,6 +444,7 @@ export default function SchedulesPage() {
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);
@@ -475,6 +476,13 @@ export default function SchedulesPage() {
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]);
@@ -486,6 +494,18 @@ export default function SchedulesPage() {
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 toggleTimeline = async (id: number) => {
if (expandedId === id) {
setExpandedId(null);
@@ -553,6 +573,38 @@ export default function SchedulesPage() {
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 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} 个计划`);
+ setSelectedIds([]);
+ await load();
+ };
let scheduleContent: React.ReactNode;
if (loading) {
scheduleContent = (
@@ -597,6 +649,24 @@ export default function SchedulesPage() {
共 {filteredItems.length} 条
+ {selectedIds.length ? (
+
+
已选 {selectedIds.length} 个计划
+
+ void bulkRun()}>
+
+ 立即运行
+
+ void bulkRemove()}>
+
+ 删除
+
+ setSelectedIds([])}>
+ 清空选择
+
+
+
+ ) : 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();
};