mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
Merge pull request #4 from RuoJi6/codex/schedule-date-range
支持计划任务日期范围与日历选择
This commit is contained in:
12 files changed
+900
-139
No files matched your search
@@ -4,6 +4,13 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.17] - 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` 以绑定挂载持久化。
|
||||
|
||||
`.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 # 清理旧镜像(可选)
|
||||
|
||||
+63
-16
@@ -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
|
||||
@@ -82,42 +85,58 @@ 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("截止日期格式不正确")
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return in, fmt.Errorf("截止日期不能早于开始日期")
|
||||
}
|
||||
if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" {
|
||||
if in.EndTime <= in.StartTime {
|
||||
return in, fmt.Errorf("同一天的结束时间必须晚于开始时间")
|
||||
}
|
||||
}
|
||||
}
|
||||
in.Weekdays = nil
|
||||
} else {
|
||||
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 = ""
|
||||
in.EndDate = ""
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
@@ -292,6 +311,31 @@ 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 {
|
||||
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)
|
||||
@@ -315,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+65
-14
@@ -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}
|
||||
}
|
||||
|
||||
@@ -41,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" {
|
||||
@@ -62,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})
|
||||
}
|
||||
@@ -70,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)
|
||||
@@ -83,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)
|
||||
}
|
||||
@@ -120,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)
|
||||
@@ -144,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 {
|
||||
@@ -157,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 {
|
||||
@@ -184,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)
|
||||
@@ -192,23 +199,67 @@ 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.runScheduleTasksNow(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) {
|
||||
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 {
|
||||
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.TaskPausedBySchedule(taskID)
|
||||
if err != nil {
|
||||
errors = append(errors, fmt.Sprintf("任务 #%d 状态读取失败", taskID))
|
||||
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", false, "状态读取失败")
|
||||
continue
|
||||
}
|
||||
if !managed && !forceManualPause {
|
||||
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)
|
||||
message := "立即运行"
|
||||
if !managed && forceManualPause {
|
||||
message = "立即运行(显式恢复人工暂停)"
|
||||
}
|
||||
_ = s.m.pg.RecordScheduleHistory(item.ID, taskID, "resume", true, message)
|
||||
}
|
||||
return started, errors
|
||||
}
|
||||
|
||||
// scheduleRequestFromTask is used by the task creation handler without making
|
||||
|
||||
@@ -2,9 +2,21 @@
|
||||
|
||||
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";
|
||||
import { TimeSelect } from "@/components/time-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,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";
|
||||
|
||||
@@ -40,7 +53,7 @@ function statusText(s: TaskSchedule) {
|
||||
error: "错误",
|
||||
paused: "已暂停",
|
||||
} as Record<string, string>
|
||||
)[s.status] ?? s.status
|
||||
)[s.status] ?? "未知状态"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -48,10 +61,193 @@ function formatTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN") : "—";
|
||||
}
|
||||
|
||||
function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" {
|
||||
if (status === "running") return "default";
|
||||
if (status === "error") return "destructive";
|
||||
return "secondary";
|
||||
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 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.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);
|
||||
const start = formatClock(item.start_time);
|
||||
return `${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", "规则");
|
||||
}
|
||||
|
||||
type ScheduleHistoryEntry = NonNullable<TaskSchedule["history"]>[number];
|
||||
|
||||
function historyActionText(action: string) {
|
||||
return (
|
||||
(
|
||||
{
|
||||
running: "窗口开始",
|
||||
outside: "窗口结束",
|
||||
completed: "计划完成",
|
||||
waiting: "等待窗口",
|
||||
paused: "计划暂停",
|
||||
resume: "恢复任务",
|
||||
pause: "暂停任务",
|
||||
error: "执行错误",
|
||||
} as Record<string, string>
|
||||
)[action] ?? "状态变化"
|
||||
);
|
||||
}
|
||||
|
||||
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 }) {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-4 text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
加载运行时间线…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// 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 <div className="py-4 text-muted-foreground text-sm">暂无运行记录,计划进入第一个窗口后会显示时间线。</div>;
|
||||
}
|
||||
return (
|
||||
<div className="grid gap-3 rounded-lg border bg-muted/20 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="font-medium text-sm">运行时间线</div>
|
||||
<span className="text-muted-foreground text-xs">最近 {Math.min(events.length, 20)} 条记录</span>
|
||||
</div>
|
||||
<div className="grid gap-0">
|
||||
{events.slice(0, 20).map((event, index) => (
|
||||
<div key={event.id} className="relative flex gap-3 pb-3 last:pb-0">
|
||||
{index < Math.min(events.length, 20) - 1 ? (
|
||||
<span className="absolute top-4 bottom-0 left-[5px] w-px bg-border" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={`relative mt-1.5 size-3 shrink-0 rounded-full border-2 border-background shadow-sm ${historyDotClass(event)}`}
|
||||
title={historyActionText(event.action)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 rounded-md border bg-background px-3 py-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge variant="outline" className={historyEventClass(event)}>
|
||||
{historyActionText(event.action)}
|
||||
</Badge>
|
||||
<span className="text-muted-foreground">{formatTime(event.created_at)}</span>
|
||||
<span className="text-muted-foreground">{event.task_id ? `任务 #${event.task_id}` : "计划任务"}</span>
|
||||
</div>
|
||||
{event.message ? (
|
||||
<div className={`mt-1 text-xs ${event.success ? "text-muted-foreground" : "text-destructive"}`}>
|
||||
{event.message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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<TaskSchedule["status"], string>
|
||||
)[status];
|
||||
}
|
||||
|
||||
function ScheduleForm({
|
||||
@@ -145,9 +341,25 @@ function ScheduleForm({
|
||||
</div>
|
||||
</div>
|
||||
{draft.schedule_type === "once" ? (
|
||||
<div className="grid gap-2">
|
||||
<Label>执行日期</Label>
|
||||
<Input type="date" value={draft.run_date ?? ""} onChange={(e) => set({ run_date: e.target.value })} />
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid gap-2">
|
||||
<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 className="grid gap-2">
|
||||
@@ -186,7 +398,7 @@ function ScheduleForm({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
结束时间为空表示开始后持续运行;结束时间早于开始时间表示跨午夜窗口。
|
||||
一次性计划可设置截止日期;截止日期为空时仅按结束时间控制。结束时间为空表示持续运行,结束时间早于开始时间表示跨午夜窗口。
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
@@ -230,6 +442,13 @@ 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 [selectedIds, setSelectedIds] = React.useState<number[]>([]);
|
||||
const [expandedId, setExpandedId] = React.useState<number | null>(null);
|
||||
const [timelineById, setTimelineById] = React.useState<Record<number, TaskSchedule>>({});
|
||||
const [timelineLoadingId, setTimelineLoadingId] = React.useState<number | null>(null);
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -245,6 +464,78 @@ 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);
|
||||
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]);
|
||||
const updateQuery = (value: string) => {
|
||||
setQuery(value);
|
||||
setPage(1);
|
||||
};
|
||||
const updatePageSize = (value: string) => {
|
||||
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 refreshTimeline = async (id: number) => {
|
||||
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 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() ||
|
||||
@@ -271,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);
|
||||
@@ -280,12 +577,63 @@ 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} 个任务未启动:${result.errors.join(";")}`,
|
||||
);
|
||||
} else {
|
||||
toast.success(`已立即运行 ${result.started} 个任务`);
|
||||
}
|
||||
}
|
||||
await invalidateTimeline(id);
|
||||
await load();
|
||||
} catch (e) {
|
||||
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 Promise.all(ids.map((id) => invalidateTimeline(id)));
|
||||
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} 个计划`);
|
||||
setTimelineById((current) => {
|
||||
const next = { ...current };
|
||||
ids.forEach((id) => {
|
||||
delete next[id];
|
||||
});
|
||||
return next;
|
||||
});
|
||||
if (expandedId && ids.includes(expandedId)) setExpandedId(null);
|
||||
setSelectedIds([]);
|
||||
await load();
|
||||
};
|
||||
let scheduleContent: React.ReactNode;
|
||||
if (loading) {
|
||||
scheduleContent = (
|
||||
@@ -304,80 +652,227 @@ export default function SchedulesPage() {
|
||||
);
|
||||
} else {
|
||||
scheduleContent = (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className="text-base">{item.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{item.schedule_type === "once"
|
||||
? item.run_date
|
||||
: (item.weekdays ?? []).map((d) => weekdays[d - 1]).join("、")}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Badge variant={statusVariant(item.status)}>{statusText(item)}</Badge>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{item.start_time.slice(0, 5)}
|
||||
{item.end_time ? ` - ${item.end_time.slice(0, 5)}` : " 起持续运行"} ·{" "}
|
||||
{item.timezone_mode === "beijing" ? "北京时间" : item.timezone}
|
||||
</div>
|
||||
<div>绑定任务:{item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"}</div>
|
||||
<div className="text-muted-foreground text-xs">
|
||||
最近变化:{formatTime(item.last_transition_at)}
|
||||
{item.last_error ? ` · ${item.last_error}` : ""}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
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,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{item.enabled ? (
|
||||
<Button size="sm" variant="outline" onClick={() => void action(item.id, "pause")}>
|
||||
<Pause data-icon="inline-start" />
|
||||
暂停
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => void action(item.id, "resume")}>
|
||||
<Play data-icon="inline-start" />
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => void action(item.id, "run")}>
|
||||
<Zap data-icon="inline-start" />
|
||||
立即运行
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void remove(item.id)}>
|
||||
<Trash2 data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
<div className="grid gap-3">
|
||||
<div className="flex flex-col gap-3 rounded-lg border bg-card p-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-md">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(event) => updateQuery(event.target.value)}
|
||||
placeholder="检索计划名称、计划 ID 或任务 ID"
|
||||
className="h-9 pl-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<span className="text-muted-foreground">每页</span>
|
||||
<Select value={pageSize} onValueChange={updatePageSize}>
|
||||
<SelectTrigger className="h-9 w-24 bg-background">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10 条</SelectItem>
|
||||
<SelectItem value="20">20 条</SelectItem>
|
||||
<SelectItem value="50">50 条</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground">共 {filteredItems.length} 条</span>
|
||||
</div>
|
||||
</div>
|
||||
{selectedIds.length ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-primary/20 bg-primary/5 px-3 py-2">
|
||||
<span className="text-sm">已选 {selectedIds.length} 个计划</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" onClick={() => void bulkRun()}>
|
||||
<Zap data-icon="inline-start" />
|
||||
立即运行
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" onClick={() => void bulkRemove()}>
|
||||
<Trash2 data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelectedIds([])}>
|
||||
清空选择
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{filteredItems.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-10 text-center text-muted-foreground">没有匹配的计划任务。</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border bg-card">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/40">
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
aria-label="选择当前页计划"
|
||||
checked={headerSelectionState}
|
||||
onCheckedChange={(checked) => toggleAllVisible(checked === true)}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="min-w-44">计划名称</TableHead>
|
||||
<TableHead className="w-28">时间线</TableHead>
|
||||
<TableHead className="min-w-48">运行周期</TableHead>
|
||||
<TableHead className="min-w-40">开始时间</TableHead>
|
||||
<TableHead className="min-w-40">结束时间</TableHead>
|
||||
<TableHead className="min-w-24 text-center">运行次数</TableHead>
|
||||
<TableHead className="min-w-36">绑定任务</TableHead>
|
||||
<TableHead className="min-w-32">状态</TableHead>
|
||||
<TableHead className="min-w-72 text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{visibleItems.map((item) => (
|
||||
<React.Fragment key={item.id}>
|
||||
<TableRow>
|
||||
<TableCell className="w-12">
|
||||
<Checkbox
|
||||
aria-label={`选择计划 ${item.name}`}
|
||||
checked={selectedIds.includes(item.id)}
|
||||
onCheckedChange={(checked) => toggleSelected(item.id, checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
<div className="text-muted-foreground text-xs">计划 #{item.id}</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="outline" onClick={() => void toggleTimeline(item.id)}>
|
||||
{expandedId === item.id ? (
|
||||
<ChevronDown data-icon="inline-start" />
|
||||
) : (
|
||||
<ChevronRight data-icon="inline-start" />
|
||||
)}
|
||||
{expandedId === item.id ? "收起" : "查看"}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{periodLabel(item)}</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm">
|
||||
{startLabel(item)}
|
||||
{item.next_start ? (
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
下次:{nextWindowLabel(item.next_start, item.timezone)}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-sm">
|
||||
{endLabel(item)}
|
||||
{item.next_end ? (
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
下次:{nextWindowLabel(item.next_end, item.timezone)}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-sm tabular-nums">
|
||||
{item.run_count ?? 0}
|
||||
{item.last_run_at ? (
|
||||
<div className="mt-1 text-muted-foreground text-xs">最近 {formatTime(item.last_run_at)}</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-52 truncate text-sm"
|
||||
title={item.task_ids.map((id) => `#${id}`).join("、")}
|
||||
>
|
||||
{item.task_ids.length ? item.task_ids.map((id) => `#${id}`).join("、") : "无"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className={statusClass(item.status)}>
|
||||
{statusText(item)}
|
||||
</Badge>
|
||||
<div className="mt-1 text-muted-foreground text-xs">{timezoneLabel(item)}</div>
|
||||
<div className="mt-1 text-muted-foreground text-xs">
|
||||
最近:{formatTime(item.last_transition_at)}
|
||||
</div>
|
||||
{item.last_error ? (
|
||||
<div
|
||||
className="mt-1 max-w-40 truncate text-destructive text-xs"
|
||||
title={scheduleErrorText(item.last_error)}
|
||||
>
|
||||
{scheduleErrorText(item.last_error)}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
{item.enabled ? (
|
||||
<Button size="sm" variant="outline" onClick={() => void action(item.id, "pause")}>
|
||||
<Pause data-icon="inline-start" />
|
||||
暂停
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="outline" onClick={() => void action(item.id, "resume")}>
|
||||
<Play data-icon="inline-start" />
|
||||
启用
|
||||
</Button>
|
||||
)}
|
||||
<Button size="sm" variant="outline" onClick={() => void action(item.id, "run")}>
|
||||
<Zap data-icon="inline-start" />
|
||||
立即运行
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void remove(item.id)}>
|
||||
<Trash2 data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedId === item.id ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={10} className="bg-muted/10 p-3">
|
||||
<ScheduleTimeline schedule={timelineById[item.id]} loading={timelineLoadingId === item.id} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : null}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="text-muted-foreground">
|
||||
第 {page} / {totalPages} 页
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((current) => current - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage((current) => current + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,10 +45,12 @@ import {
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { AssetInterceptRulesEditor } from "@/components/asset-intercept-rules-editor";
|
||||
import { DateSelect } from "@/components/date-select";
|
||||
import { StatusBadge } from "@/components/status-badge";
|
||||
import { TablePagination } from "@/components/table-pagination";
|
||||
import { TaskLLMProfileChain } from "@/components/task-llm-profile-chain";
|
||||
import { TaskTemplateControls } from "@/components/task-template-controls";
|
||||
import { TimeSelect } from "@/components/time-select";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -117,7 +119,6 @@ import { Switch } from "@/components/ui/switch";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TimeSelect } from "@/components/time-select";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { api, type ScheduleInput } from "@/lib/api";
|
||||
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
|
||||
@@ -3101,6 +3102,7 @@ function CreateTaskSheet({
|
||||
const [schedule, setSchedule] = React.useState<ScheduleInput>({
|
||||
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({
|
||||
</Field>
|
||||
</div>
|
||||
{schedule.schedule_type === "once" ? (
|
||||
<Field>
|
||||
<FieldLabel>执行日期</FieldLabel>
|
||||
<Input
|
||||
type="date"
|
||||
value={schedule.run_date ?? ""}
|
||||
onChange={(e) => setSchedule({ ...schedule, run_date: e.target.value })}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field>
|
||||
<FieldLabel>开始日期</FieldLabel>
|
||||
<DateSelect
|
||||
value={schedule.run_date ?? ""}
|
||||
onValueChange={(value) => setSchedule({ ...schedule, run_date: value })}
|
||||
aria-label="开始日期"
|
||||
/>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel>截止日期(可选)</FieldLabel>
|
||||
<DateSelect
|
||||
value={schedule.end_date ?? ""}
|
||||
onValueChange={(value) => setSchedule({ ...schedule, end_date: value })}
|
||||
optional
|
||||
placeholder="不设置截止日期"
|
||||
aria-label="截止日期,可选"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
) : (
|
||||
<Field>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
+4
-1
@@ -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 ?? "",
|
||||
@@ -326,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<Task>(`/tasks/${id}`, input),
|
||||
renameTask: (id: string, name: string) => patch<Task>(`/tasks/${id}`, { name }),
|
||||
|
||||
@@ -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;
|
||||
@@ -733,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;
|
||||
|
||||
Reference in new issue
Block a user