支持计划任务日期范围

This commit is contained in:
ji ruo committed 2026-10-09 23:50:05 +08:00
1 parent 6a2bba4637
commit 98321d8262
12 files changed
+238 -35

No files matched your search

+7
View File
@@ -4,6 +4,13 @@
## [Unreleased] ## [Unreleased]
## [0.3.17] - 2026-10-09
### 计划任务
- 新增日历式计划任务,支持按时间规则创建、编辑、启停和删除任务。
- 任务列表支持查看计划状态与下一次运行时间,并补充计划任务管理页面。
## [0.3.16] - 2026-10-09 ## [0.3.16] - 2026-10-09
### 访问控制 ### 访问控制
+7 -7
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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();
+22 -2
View File
@@ -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
+36 -6
View File
@@ -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
View File
@@ -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}
} }
+22 -4
View File
@@ -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),
+24 -9
View File
@@ -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>
+88
View File
@@ -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>
);
}
+2
View File
@@ -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 ?? "",
+1
View File
@@ -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;