mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +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]
|
||||
|
||||
## [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 # 清理旧镜像(可选)
|
||||
|
||||
+25
-6
@@ -18,6 +18,7 @@ type TaskSchedule struct {
|
||||
TimezoneMode string `json:"timezone_mode"`
|
||||
ScheduleType string `json:"schedule_type"`
|
||||
RunDate string `json:"run_date,omitempty"`
|
||||
EndDate string `json:"end_date,omitempty"`
|
||||
Weekdays []int `json:"weekdays,omitempty"`
|
||||
StartTime string `json:"start_time"`
|
||||
EndTime string `json:"end_time,omitempty"`
|
||||
@@ -37,6 +38,7 @@ type TaskScheduleInput struct {
|
||||
TimezoneMode string
|
||||
ScheduleType string
|
||||
RunDate string
|
||||
EndDate string
|
||||
Weekdays []int
|
||||
StartTime string
|
||||
EndTime string
|
||||
@@ -46,6 +48,7 @@ type TaskScheduleInput struct {
|
||||
|
||||
const scheduleCols = `id,name,enabled,timezone_mode,schedule_type,
|
||||
COALESCE(to_char(run_date,'YYYY-MM-DD'),''),weekdays,to_char(start_time,'HH24:MI:SS'),
|
||||
COALESCE(to_char(end_date,'YYYY-MM-DD'),''),
|
||||
COALESCE(to_char(end_time,'HH24:MI:SS'),''),start_immediately,status,last_window_key,
|
||||
last_transition_at,last_error,created_at,updated_at`
|
||||
|
||||
@@ -61,7 +64,7 @@ func scanSchedule(sc interface{ Scan(...any) error }) (*TaskSchedule, error) {
|
||||
var s TaskSchedule
|
||||
var rawDays string
|
||||
if err := sc.Scan(&s.ID, &s.Name, &s.Enabled, &s.TimezoneMode, &s.ScheduleType,
|
||||
&s.RunDate, &rawDays, &s.StartTime, &s.EndTime, &s.StartImmediately,
|
||||
&s.RunDate, &rawDays, &s.StartTime, &s.EndDate, &s.EndTime, &s.StartImmediately,
|
||||
&s.Status, &s.LastWindowKey, &s.LastTransitionAt, &s.LastError,
|
||||
&s.CreatedAt, &s.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
@@ -99,6 +102,21 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
||||
if _, err := time.Parse("2006-01-02", in.RunDate); err != nil {
|
||||
return in, fmt.Errorf("run_date 必须为 YYYY-MM-DD")
|
||||
}
|
||||
if in.EndDate != "" {
|
||||
startDate, _ := time.Parse("2006-01-02", in.RunDate)
|
||||
endDate, err := time.Parse("2006-01-02", in.EndDate)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("end_date 必须为 YYYY-MM-DD")
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return in, fmt.Errorf("end_date 不能早于 run_date")
|
||||
}
|
||||
if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" {
|
||||
if in.EndTime <= in.StartTime {
|
||||
return in, fmt.Errorf("同一天的 end_time 必须晚于 start_time")
|
||||
}
|
||||
}
|
||||
}
|
||||
in.Weekdays = nil
|
||||
} else {
|
||||
seen := map[int]bool{}
|
||||
@@ -113,6 +131,7 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
||||
}
|
||||
sort.Ints(in.Weekdays)
|
||||
in.RunDate = ""
|
||||
in.EndDate = ""
|
||||
}
|
||||
seenTasks := map[int64]bool{}
|
||||
for _, id := range in.TaskIDs {
|
||||
@@ -152,9 +171,9 @@ func createTaskScheduleTxCommit(tx *sql.Tx, in TaskScheduleInput) (*TaskSchedule
|
||||
}
|
||||
|
||||
func insertTaskSchedule(tx *sql.Tx, in TaskScheduleInput) (*TaskSchedule, error) {
|
||||
row := tx.QueryRow(`INSERT INTO task_schedules(name,enabled,timezone_mode,schedule_type,run_date,weekdays,start_time,end_time,start_immediately)
|
||||
VALUES ($1,$2,$3,$4,NULLIF($5,'')::date,$6,$7::time,NULLIF($8,'')::time,$9) RETURNING `+scheduleCols,
|
||||
in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
|
||||
row := tx.QueryRow(`INSERT INTO task_schedules(name,enabled,timezone_mode,schedule_type,run_date,end_date,weekdays,start_time,end_time,start_immediately)
|
||||
VALUES ($1,$2,$3,$4,NULLIF($5,'')::date,NULLIF($6,'')::date,$7,$8::time,NULLIF($9,'')::time,$10) RETURNING `+scheduleCols,
|
||||
in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, in.EndDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
|
||||
s, err := scanSchedule(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -188,8 +207,8 @@ func (d *DB) UpdateTaskSchedule(id int64, in TaskScheduleInput) (*TaskSchedule,
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
row := tx.QueryRow(`UPDATE task_schedules SET name=$2,enabled=$3,timezone_mode=$4,schedule_type=$5,run_date=NULLIF($6,'')::date,weekdays=$7,start_time=$8::time,end_time=NULLIF($9,'')::time,start_immediately=$10,status=CASE WHEN $3 THEN CASE WHEN status='completed' THEN 'waiting' ELSE status END ELSE 'paused' END,last_error='',last_window_key='' WHERE id=$1 RETURNING `+scheduleCols,
|
||||
id, in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
|
||||
row := tx.QueryRow(`UPDATE task_schedules SET name=$2,enabled=$3,timezone_mode=$4,schedule_type=$5,run_date=NULLIF($6,'')::date,end_date=NULLIF($7,'')::date,weekdays=$8,start_time=$9::time,end_time=NULLIF($10,'')::time,start_immediately=$11,status=CASE WHEN $3 THEN CASE WHEN status='completed' THEN 'waiting' ELSE status END ELSE 'paused' END,last_error='',last_window_key='' WHERE id=$1 RETURNING `+scheduleCols,
|
||||
id, in.Name, in.Enabled, in.TimezoneMode, in.ScheduleType, in.RunDate, in.EndDate, marshalWeekdays(in.Weekdays), in.StartTime, in.EndTime, in.StartImmediately)
|
||||
s, err := scanSchedule(row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import * as React from "react";
|
||||
import { CalendarClock, Loader2, Pause, Play, Plus, Search, Trash2, Zap } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { DateSelect } from "@/components/date-select";
|
||||
import { TimeSelect } from "@/components/time-select";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -145,9 +146,25 @@ function ScheduleForm({
|
||||
</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 +203,7 @@ function ScheduleForm({
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
结束时间为空表示开始后持续运行;结束时间早于开始时间表示跨午夜窗口。
|
||||
一次性计划可设置截止日期;截止日期为空时仅按结束时间控制。结束时间为空表示持续运行,结束时间早于开始时间表示跨午夜窗口。
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
@@ -344,6 +361,7 @@ export default function SchedulesPage() {
|
||||
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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ?? "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user