mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 01:03:50 +08:00
支持计划任务日期范围
This commit is contained in:
1 parent
6a2bba4637
commit
98321d8262
12 files changed
+238
-35
No files matched your search
@@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.3.17] - 2026-10-09
|
||||||
|
|
||||||
|
### 计划任务
|
||||||
|
|
||||||
|
- 新增日历式计划任务,支持按时间规则创建、编辑、启停和删除任务。
|
||||||
|
- 任务列表支持查看计划状态与下一次运行时间,并补充计划任务管理页面。
|
||||||
|
|
||||||
## [0.3.16] - 2026-10-09
|
## [0.3.16] - 2026-10-09
|
||||||
|
|
||||||
### 访问控制
|
### 访问控制
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ docker compose up -d # 拉取 ruoji6/artex 镜像 + postgres
|
|||||||
|
|
||||||
镜像已含常用工具(ripgrep/curl/vim/npm/nmap…);`./skills` 与 `./data` 以绑定挂载持久化。
|
镜像已含常用工具(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)。
|
远程 MCP 可在系统设置中选择 `http`(Streamable HTTP)或 `sse`(旧版 SSE)。
|
||||||
旧版 SSE 服务通常使用 `GET /sse` 建立事件流,再通过服务返回的
|
旧版 SSE 服务通常使用 `GET /sse` 建立事件流,再通过服务返回的
|
||||||
@@ -165,12 +165,12 @@ ARTEX_TARGETS=linux/amd64,windows/amd64 ./build.sh --release
|
|||||||
|
|
||||||
### 维护者:通过 GitHub Actions 发布在线更新
|
### 维护者:通过 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
|
```bash
|
||||||
git push origin main
|
git push origin main
|
||||||
git tag v0.3.16
|
git tag v0.3.17
|
||||||
git push origin v0.3.16
|
git push origin v0.3.17
|
||||||
```
|
```
|
||||||
|
|
||||||
工作流固定标签对应的提交,用 Node.js 22 导出前端,按照 `go.mod` 指定的 Go 版本编译内嵌前端的五个平台程序,并生成 ZIP 与 `SHA256SUMS`。AMD64 和 ARM64 镜像分别在 GitHub 原生 runner 上构建,连接临时 PostgreSQL 验证程序启动、版本和内嵌前端;两种架构都通过后发布多架构镜像的版本标签与 `latest`。镜像发布成功后上传完整 ZIP 和校验文件,最后公开 Release。已公开的同版本不允许覆盖,应发布新标签。
|
工作流固定标签对应的提交,用 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
|
```bash
|
||||||
# 在下载目录运行;先按 SHA256SUMS 核对下载的 ZIP,再解压。
|
# 在下载目录运行;先按 SHA256SUMS 核对下载的 ZIP,再解压。
|
||||||
sha256sum --ignore-missing --check SHA256SUMS
|
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 stop artex-artex-1
|
||||||
sudo docker cp artex-artex-1:/app/artex ./artex-before-migration
|
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
|
sudo docker start artex-artex-1
|
||||||
curl --fail http://127.0.0.1:8787/api/health
|
curl --fail http://127.0.0.1:8787/api/health
|
||||||
```
|
```
|
||||||
@@ -231,7 +231,7 @@ cd ARTEX
|
|||||||
```bash
|
```bash
|
||||||
cd ARTEX
|
cd ARTEX
|
||||||
git pull # 更新 compose / 脚本(可选)
|
git pull # 更新 compose / 脚本(可选)
|
||||||
# 指定版本:在 .env 设 ARTEX_TAG=v0.3.16;不设则用 latest
|
# 指定版本:在 .env 设 ARTEX_TAG=v0.3.17;不设则用 latest
|
||||||
docker compose pull artex
|
docker compose pull artex
|
||||||
docker compose up -d artex # 换新镜像重启 → 自动迁移 schema
|
docker compose up -d artex # 换新镜像重启 → 自动迁移 schema
|
||||||
docker image prune -f # 清理旧镜像(可选)
|
docker image prune -f # 清理旧镜像(可选)
|
||||||
|
|||||||
+25
-6
@@ -18,6 +18,7 @@ type TaskSchedule struct {
|
|||||||
TimezoneMode string `json:"timezone_mode"`
|
TimezoneMode string `json:"timezone_mode"`
|
||||||
ScheduleType string `json:"schedule_type"`
|
ScheduleType string `json:"schedule_type"`
|
||||||
RunDate string `json:"run_date,omitempty"`
|
RunDate string `json:"run_date,omitempty"`
|
||||||
|
EndDate string `json:"end_date,omitempty"`
|
||||||
Weekdays []int `json:"weekdays,omitempty"`
|
Weekdays []int `json:"weekdays,omitempty"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
EndTime string `json:"end_time,omitempty"`
|
EndTime string `json:"end_time,omitempty"`
|
||||||
@@ -37,6 +38,7 @@ type TaskScheduleInput struct {
|
|||||||
TimezoneMode string
|
TimezoneMode string
|
||||||
ScheduleType string
|
ScheduleType string
|
||||||
RunDate string
|
RunDate string
|
||||||
|
EndDate string
|
||||||
Weekdays []int
|
Weekdays []int
|
||||||
StartTime string
|
StartTime string
|
||||||
EndTime string
|
EndTime string
|
||||||
@@ -46,6 +48,7 @@ type TaskScheduleInput struct {
|
|||||||
|
|
||||||
const scheduleCols = `id,name,enabled,timezone_mode,schedule_type,
|
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(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,
|
COALESCE(to_char(end_time,'HH24:MI:SS'),''),start_immediately,status,last_window_key,
|
||||||
last_transition_at,last_error,created_at,updated_at`
|
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 s TaskSchedule
|
||||||
var rawDays string
|
var rawDays string
|
||||||
if err := sc.Scan(&s.ID, &s.Name, &s.Enabled, &s.TimezoneMode, &s.ScheduleType,
|
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.Status, &s.LastWindowKey, &s.LastTransitionAt, &s.LastError,
|
||||||
&s.CreatedAt, &s.UpdatedAt); err != nil {
|
&s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -99,6 +102,21 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
|||||||
if _, err := time.Parse("2006-01-02", in.RunDate); err != nil {
|
if _, err := time.Parse("2006-01-02", in.RunDate); err != nil {
|
||||||
return in, fmt.Errorf("run_date 必须为 YYYY-MM-DD")
|
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
|
in.Weekdays = nil
|
||||||
} else {
|
} else {
|
||||||
seen := map[int]bool{}
|
seen := map[int]bool{}
|
||||||
@@ -113,6 +131,7 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
|||||||
}
|
}
|
||||||
sort.Ints(in.Weekdays)
|
sort.Ints(in.Weekdays)
|
||||||
in.RunDate = ""
|
in.RunDate = ""
|
||||||
|
in.EndDate = ""
|
||||||
}
|
}
|
||||||
seenTasks := map[int64]bool{}
|
seenTasks := map[int64]bool{}
|
||||||
for _, id := range in.TaskIDs {
|
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) {
|
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)
|
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,$6,$7::time,NULLIF($8,'')::time,$9) RETURNING `+scheduleCols,
|
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, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
|
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)
|
s, err := scanSchedule(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -188,8 +207,8 @@ func (d *DB) UpdateTaskSchedule(id int64, in TaskScheduleInput) (*TaskSchedule,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
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,
|
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, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
|
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)
|
s, err := scanSchedule(row)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1039,6 +1039,7 @@ CREATE TABLE IF NOT EXISTS task_schedules (
|
|||||||
schedule_type TEXT NOT NULL DEFAULT 'weekly'
|
schedule_type TEXT NOT NULL DEFAULT 'weekly'
|
||||||
CHECK (schedule_type IN ('once','weekly')),
|
CHECK (schedule_type IN ('once','weekly')),
|
||||||
run_date DATE,
|
run_date DATE,
|
||||||
|
end_date DATE,
|
||||||
weekdays TEXT NOT NULL DEFAULT '',
|
weekdays TEXT NOT NULL DEFAULT '',
|
||||||
start_time TIME NOT NULL,
|
start_time TIME NOT NULL,
|
||||||
end_time TIME,
|
end_time TIME,
|
||||||
@@ -1055,6 +1056,7 @@ CREATE TABLE IF NOT EXISTS task_schedules (
|
|||||||
CHECK ((schedule_type = 'once') OR weekdays <> '')
|
CHECK ((schedule_type = 'once') OR weekdays <> '')
|
||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_task_schedules_enabled ON task_schedules(enabled, id);
|
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;
|
DROP TRIGGER IF EXISTS trg_task_schedules_upd ON task_schedules;
|
||||||
CREATE TRIGGER trg_task_schedules_upd BEFORE UPDATE ON task_schedules
|
CREATE TRIGGER trg_task_schedules_upd BEFORE UPDATE ON task_schedules
|
||||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||||
|
|||||||
@@ -56,12 +56,32 @@ func evaluateScheduleWindow(s *db.TaskSchedule, now time.Time) scheduleWindow {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return scheduleWindow{}
|
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))
|
active := !now.Before(start) && (end == nil || now.Before(*end))
|
||||||
complete := end != nil && !now.Before(*end)
|
complete := end != nil && !now.Before(*end)
|
||||||
// An open-ended one-shot remains active after its start until manually
|
// 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.
|
// 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) {
|
if now.Before(start) {
|
||||||
w.key = ""
|
w.key = ""
|
||||||
w.nextStart = &start
|
w.nextStart = &start
|
||||||
|
|||||||
@@ -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"}
|
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.
|
// 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)))
|
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.
|
// 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)))
|
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) {
|
func TestEvaluateScheduleWindowOnce(t *testing.T) {
|
||||||
s := &db.TaskSchedule{ScheduleType: "once", TimezoneMode: "beijing", RunDate: "2026-10-09", StartTime: "18:00:00", EndTime: "23:00:00"}
|
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)
|
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, 17, 59, 0, 0, loc)); w.active {
|
||||||
if w := evaluateScheduleWindow(s, time.Date(2026, 10, 9, 19, 0, 0, 0, loc)); !w.active { t.Fatal("one-shot should be active") }
|
t.Fatal("one-shot fired early")
|
||||||
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, 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) {
|
func TestEvaluateScheduleWindowOpenEnded(t *testing.T) {
|
||||||
s := &db.TaskSchedule{ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{6}, StartTime: "09:00:00"}
|
s := &db.TaskSchedule{ScheduleType: "weekly", TimezoneMode: "beijing", Weekdays: []int{6}, StartTime: "09:00:00"}
|
||||||
loc := time.FixedZone("CST", 8*3600)
|
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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -18,6 +18,7 @@ type scheduleRequest struct {
|
|||||||
TimezoneMode string `json:"timezone_mode"`
|
TimezoneMode string `json:"timezone_mode"`
|
||||||
ScheduleType string `json:"schedule_type"`
|
ScheduleType string `json:"schedule_type"`
|
||||||
RunDate string `json:"run_date"`
|
RunDate string `json:"run_date"`
|
||||||
|
EndDate string `json:"end_date"`
|
||||||
Weekdays []int `json:"weekdays"`
|
Weekdays []int `json:"weekdays"`
|
||||||
StartTime string `json:"start_time"`
|
StartTime string `json:"start_time"`
|
||||||
EndTime string `json:"end_time"`
|
EndTime string `json:"end_time"`
|
||||||
@@ -33,7 +34,7 @@ func (s *Server) scheduleInput(req scheduleRequest, existing *db.TaskSchedule) d
|
|||||||
enabled = existing.Enabled
|
enabled = existing.Enabled
|
||||||
}
|
}
|
||||||
return db.TaskScheduleInput{Name: req.Name, Enabled: enabled, TimezoneMode: req.TimezoneMode, ScheduleType: req.ScheduleType,
|
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}
|
StartImmediately: req.StartImmediately, TaskIDs: req.TaskIDs}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
|||||||
import { CalendarClock, Loader2, Pause, Play, Plus, Search, Trash2, Zap } from "lucide-react";
|
import { CalendarClock, Loader2, Pause, Play, Plus, Search, Trash2, Zap } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { DateSelect } from "@/components/date-select";
|
||||||
import { TimeSelect } from "@/components/time-select";
|
import { TimeSelect } from "@/components/time-select";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -145,9 +146,25 @@ function ScheduleForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{draft.schedule_type === "once" ? (
|
{draft.schedule_type === "once" ? (
|
||||||
<div className="grid gap-2">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<Label>执行日期</Label>
|
<div className="grid gap-2">
|
||||||
<Input type="date" value={draft.run_date ?? ""} onChange={(e) => set({ run_date: e.target.value })} />
|
<Label>开始日期</Label>
|
||||||
|
<DateSelect
|
||||||
|
value={draft.run_date ?? ""}
|
||||||
|
onValueChange={(value) => set({ run_date: value })}
|
||||||
|
aria-label="开始日期"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>截止日期(可选)</Label>
|
||||||
|
<DateSelect
|
||||||
|
value={draft.end_date ?? ""}
|
||||||
|
onValueChange={(value) => set({ end_date: value })}
|
||||||
|
optional
|
||||||
|
placeholder="不设置截止日期"
|
||||||
|
aria-label="截止日期,可选"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
@@ -186,7 +203,7 @@ function ScheduleForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
结束时间为空表示开始后持续运行;结束时间早于开始时间表示跨午夜窗口。
|
一次性计划可设置截止日期;截止日期为空时仅按结束时间控制。结束时间为空表示持续运行,结束时间早于开始时间表示跨午夜窗口。
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -344,6 +361,7 @@ export default function SchedulesPage() {
|
|||||||
timezone_mode: item.timezone_mode,
|
timezone_mode: item.timezone_mode,
|
||||||
schedule_type: item.schedule_type,
|
schedule_type: item.schedule_type,
|
||||||
run_date: item.run_date,
|
run_date: item.run_date,
|
||||||
|
end_date: item.end_date,
|
||||||
weekdays: item.weekdays,
|
weekdays: item.weekdays,
|
||||||
start_time: item.start_time.slice(0, 5),
|
start_time: item.start_time.slice(0, 5),
|
||||||
end_time: item.end_time?.slice(0, 5),
|
end_time: item.end_time?.slice(0, 5),
|
||||||
|
|||||||
@@ -45,10 +45,12 @@ import {
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
|
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
|
||||||
|
import { DateSelect } from "@/components/date-select";
|
||||||
import { StatusBadge } from "@/components/status-badge";
|
import { StatusBadge } from "@/components/status-badge";
|
||||||
import { TablePagination } from "@/components/table-pagination";
|
import { TablePagination } from "@/components/table-pagination";
|
||||||
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
|
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
|
||||||
import { TaskTemplateControls } from "@/components/task-template-controls";
|
import { TaskTemplateControls } from "@/components/task-template-controls";
|
||||||
|
import { TimeSelect } from "@/components/time-select";
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -117,7 +119,6 @@ import { Switch } from "@/components/ui/switch";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { TimeSelect } from "@/components/time-select";
|
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { api, type ScheduleInput } from "@/lib/api";
|
import { api, type ScheduleInput } from "@/lib/api";
|
||||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||||
@@ -3101,6 +3102,7 @@ function CreateTaskSheet({
|
|||||||
const [schedule, setSchedule] = React.useState<ScheduleInput>({
|
const [schedule, setSchedule] = React.useState<ScheduleInput>({
|
||||||
name: "新建任务计划",
|
name: "新建任务计划",
|
||||||
schedule_type: "weekly",
|
schedule_type: "weekly",
|
||||||
|
end_date: "",
|
||||||
weekdays: [6, 7],
|
weekdays: [6, 7],
|
||||||
start_time: "18:00",
|
start_time: "18:00",
|
||||||
end_time: "",
|
end_time: "",
|
||||||
@@ -3197,6 +3199,7 @@ function CreateTaskSheet({
|
|||||||
setSchedule({
|
setSchedule({
|
||||||
name: "新建任务计划",
|
name: "新建任务计划",
|
||||||
schedule_type: "weekly",
|
schedule_type: "weekly",
|
||||||
|
end_date: "",
|
||||||
weekdays: [6, 7],
|
weekdays: [6, 7],
|
||||||
start_time: "18:00",
|
start_time: "18:00",
|
||||||
end_time: "",
|
end_time: "",
|
||||||
@@ -3321,14 +3324,26 @@ function CreateTaskSheet({
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
{schedule.schedule_type === "once" ? (
|
{schedule.schedule_type === "once" ? (
|
||||||
<Field>
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<FieldLabel>执行日期</FieldLabel>
|
<Field>
|
||||||
<Input
|
<FieldLabel>开始日期</FieldLabel>
|
||||||
type="date"
|
<DateSelect
|
||||||
value={schedule.run_date ?? ""}
|
value={schedule.run_date ?? ""}
|
||||||
onChange={(e) => setSchedule({ ...schedule, run_date: e.target.value })}
|
onValueChange={(value) => setSchedule({ ...schedule, run_date: value })}
|
||||||
/>
|
aria-label="开始日期"
|
||||||
</Field>
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field>
|
||||||
|
<FieldLabel>截止日期(可选)</FieldLabel>
|
||||||
|
<DateSelect
|
||||||
|
value={schedule.end_date ?? ""}
|
||||||
|
onValueChange={(value) => setSchedule({ ...schedule, end_date: value })}
|
||||||
|
optional
|
||||||
|
placeholder="不设置截止日期"
|
||||||
|
aria-label="截止日期,可选"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Field>
|
<Field>
|
||||||
<FieldLabel>星期(1=周一,7=周日)</FieldLabel>
|
<FieldLabel>星期(1=周一,7=周日)</FieldLabel>
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="h-10 w-full justify-between bg-background px-3 font-normal"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
aria-expanded={open}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<CalendarDays className="size-4 text-muted-foreground" />
|
||||||
|
{selected ? (
|
||||||
|
format(selected, "yyyy年M月d日", { locale: zhCN })
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">{placeholder}</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<ChevronDown className="size-4 text-muted-foreground" />
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="start" className="w-auto overflow-hidden p-0">
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
locale={zhCN}
|
||||||
|
captionLayout="dropdown"
|
||||||
|
startMonth={new Date(2020, 0, 1)}
|
||||||
|
endMonth={new Date(2100, 11, 31)}
|
||||||
|
selected={selected}
|
||||||
|
defaultMonth={selected ?? new Date()}
|
||||||
|
onSelect={(date) => {
|
||||||
|
onValueChange(date ? format(date, "yyyy-MM-dd") : "");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{optional && selected && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full rounded-none border-t"
|
||||||
|
onClick={() => {
|
||||||
|
onValueChange("");
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<X data-icon="inline-start" />
|
||||||
|
清除日期
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -252,6 +252,7 @@ export type ScheduleInput = {
|
|||||||
timezone_mode?: "beijing" | "system";
|
timezone_mode?: "beijing" | "system";
|
||||||
schedule_type: "once" | "weekly";
|
schedule_type: "once" | "weekly";
|
||||||
run_date?: string;
|
run_date?: string;
|
||||||
|
end_date?: string;
|
||||||
weekdays?: number[];
|
weekdays?: number[];
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
@@ -266,6 +267,7 @@ function schedulePayload(input: ScheduleInput) {
|
|||||||
timezone_mode: input.timezone_mode ?? "beijing",
|
timezone_mode: input.timezone_mode ?? "beijing",
|
||||||
schedule_type: input.schedule_type,
|
schedule_type: input.schedule_type,
|
||||||
run_date: input.run_date ?? "",
|
run_date: input.run_date ?? "",
|
||||||
|
end_date: input.end_date ?? "",
|
||||||
weekdays: input.weekdays ?? [],
|
weekdays: input.weekdays ?? [],
|
||||||
start_time: input.start_time,
|
start_time: input.start_time,
|
||||||
end_time: input.end_time ?? "",
|
end_time: input.end_time ?? "",
|
||||||
|
|||||||
@@ -723,6 +723,7 @@ export interface TaskSchedule {
|
|||||||
timezone?: string;
|
timezone?: string;
|
||||||
schedule_type: "once" | "weekly";
|
schedule_type: "once" | "weekly";
|
||||||
run_date?: string;
|
run_date?: string;
|
||||||
|
end_date?: string;
|
||||||
weekdays: number[];
|
weekdays: number[];
|
||||||
start_time: string;
|
start_time: string;
|
||||||
end_time?: string;
|
end_time?: string;
|
||||||
|
|||||||
Reference in new issue
Block a user