mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-27 17:03:49 +08:00
优化计划任务列表展示
This commit is contained in:
1 parent
98321d8262
commit
30db68a86a
4 files changed
+203
-84
No files matched your search
+31
-11
@@ -85,35 +85,35 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
||||
in.TimezoneMode = "beijing"
|
||||
}
|
||||
if in.TimezoneMode != "beijing" && in.TimezoneMode != "system" {
|
||||
return in, fmt.Errorf("timezone_mode 必须为 beijing 或 system")
|
||||
return in, fmt.Errorf("时区必须选择北京时间或系统当前时区")
|
||||
}
|
||||
if in.ScheduleType != "once" && in.ScheduleType != "weekly" {
|
||||
return in, fmt.Errorf("schedule_type 必须为 once 或 weekly")
|
||||
return in, fmt.Errorf("规则必须选择一次性或按周")
|
||||
}
|
||||
if _, err := time.Parse("15:04", in.StartTime); err != nil {
|
||||
return in, fmt.Errorf("start_time 必须为 HH:MM")
|
||||
return in, fmt.Errorf("开始时间格式不正确,请使用 HH:MM")
|
||||
}
|
||||
if in.EndTime != "" && in.EndTime != "24:00" {
|
||||
if _, err := time.Parse("15:04", in.EndTime); err != nil {
|
||||
return in, fmt.Errorf("end_time 必须为 HH:MM")
|
||||
return in, fmt.Errorf("结束时间格式不正确,请使用 HH:MM")
|
||||
}
|
||||
}
|
||||
if in.ScheduleType == "once" {
|
||||
if _, err := time.Parse("2006-01-02", in.RunDate); err != nil {
|
||||
return in, fmt.Errorf("run_date 必须为 YYYY-MM-DD")
|
||||
return in, fmt.Errorf("开始日期格式不正确")
|
||||
}
|
||||
if in.EndDate != "" {
|
||||
startDate, _ := time.Parse("2006-01-02", in.RunDate)
|
||||
endDate, err := time.Parse("2006-01-02", in.EndDate)
|
||||
if err != nil {
|
||||
return in, fmt.Errorf("end_date 必须为 YYYY-MM-DD")
|
||||
return in, fmt.Errorf("截止日期格式不正确")
|
||||
}
|
||||
if endDate.Before(startDate) {
|
||||
return in, fmt.Errorf("end_date 不能早于 run_date")
|
||||
return in, fmt.Errorf("截止日期不能早于开始日期")
|
||||
}
|
||||
if endDate.Equal(startDate) && in.EndTime != "" && in.EndTime != "24:00" {
|
||||
if in.EndTime <= in.StartTime {
|
||||
return in, fmt.Errorf("同一天的 end_time 必须晚于 start_time")
|
||||
return in, fmt.Errorf("同一天的结束时间必须晚于开始时间")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -122,12 +122,12 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
||||
seen := map[int]bool{}
|
||||
for _, day := range in.Weekdays {
|
||||
if day < 1 || day > 7 || seen[day] {
|
||||
return in, fmt.Errorf("weekdays 必须是 1-7 且不能重复")
|
||||
return in, fmt.Errorf("星期选择必须为周一至周日且不能重复")
|
||||
}
|
||||
seen[day] = true
|
||||
}
|
||||
if len(in.Weekdays) == 0 {
|
||||
return in, fmt.Errorf("weekly 计划至少选择一天")
|
||||
return in, fmt.Errorf("按周计划至少选择一天")
|
||||
}
|
||||
sort.Ints(in.Weekdays)
|
||||
in.RunDate = ""
|
||||
@@ -136,7 +136,7 @@ func normalizeScheduleInput(in TaskScheduleInput) (TaskScheduleInput, error) {
|
||||
seenTasks := map[int64]bool{}
|
||||
for _, id := range in.TaskIDs {
|
||||
if id <= 0 || seenTasks[id] {
|
||||
return in, fmt.Errorf("任务编号无效或重复")
|
||||
return in, fmt.Errorf("绑定任务编号无效或重复")
|
||||
}
|
||||
seenTasks[id] = true
|
||||
}
|
||||
@@ -311,6 +311,26 @@ func (d *DB) TouchTaskSchedule(id int64, status, windowKey, lastError string) er
|
||||
return err
|
||||
}
|
||||
|
||||
// ScheduleRunStats summarizes completed schedule window entries for list views.
|
||||
// The scheduler records one "running" history entry whenever a new window opens.
|
||||
type ScheduleRunStats struct {
|
||||
RunCount int `json:"run_count"`
|
||||
LastRunAt *time.Time `json:"last_run_at,omitempty"`
|
||||
}
|
||||
|
||||
func (d *DB) ScheduleRunStats(id int64) (ScheduleRunStats, error) {
|
||||
var stats ScheduleRunStats
|
||||
var lastRunAt sql.NullTime
|
||||
err := d.QueryRow(`
|
||||
SELECT COUNT(*) FILTER (WHERE task_id IS NULL AND action='running'),
|
||||
MAX(created_at) FILTER (WHERE task_id IS NULL AND action='running')
|
||||
FROM task_schedule_history WHERE schedule_id=$1`, id).Scan(&stats.RunCount, &lastRunAt)
|
||||
if lastRunAt.Valid {
|
||||
stats.LastRunAt = &lastRunAt.Time
|
||||
}
|
||||
return stats, err
|
||||
}
|
||||
|
||||
// TaskScheduleTasks returns all schedule IDs that reference a task.
|
||||
func (d *DB) TaskScheduleTasks(taskID int64) ([]int64, error) {
|
||||
rows, err := d.Query(`SELECT schedule_id FROM task_schedule_tasks WHERE task_id=$1 ORDER BY schedule_id`, taskID)
|
||||
|
||||
+14
-8
@@ -42,11 +42,17 @@ func parseScheduleID(r *http.Request) (int64, error) {
|
||||
return strconv.ParseInt(r.PathValue("id"), 10, 64)
|
||||
}
|
||||
|
||||
func scheduleDTO(item *db.TaskSchedule) map[string]any {
|
||||
func (s *Server) scheduleDTO(item *db.TaskSchedule) map[string]any {
|
||||
raw, _ := json.Marshal(item)
|
||||
var dto map[string]any
|
||||
_ = json.Unmarshal(raw, &dto)
|
||||
window := evaluateScheduleWindow(item, time.Now())
|
||||
if stats, err := s.m.pg.ScheduleRunStats(item.ID); err == nil {
|
||||
dto["run_count"] = stats.RunCount
|
||||
if stats.LastRunAt != nil {
|
||||
dto["last_run_at"] = stats.LastRunAt
|
||||
}
|
||||
}
|
||||
dto["timezone"] = scheduleLocation(item.TimezoneMode).String()
|
||||
dto["server_time"] = time.Now()
|
||||
if item.Enabled && item.Status != "completed" {
|
||||
@@ -63,7 +69,7 @@ func (s *Server) listSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
dtos := []map[string]any{}
|
||||
for _, item := range items {
|
||||
dtos = append(dtos, scheduleDTO(item))
|
||||
dtos = append(dtos, s.scheduleDTO(item))
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"schedules": dtos})
|
||||
}
|
||||
@@ -71,7 +77,7 @@ func (s *Server) listSchedules(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) getSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseScheduleID(r)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "bad schedule id")
|
||||
writeErr(w, 400, "计划编号无效")
|
||||
return
|
||||
}
|
||||
item, err := s.m.pg.GetTaskSchedule(id)
|
||||
@@ -84,7 +90,7 @@ func (s *Server) getSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
dto := scheduleDTO(item)
|
||||
dto := s.scheduleDTO(item)
|
||||
dto["history"] = history
|
||||
writeJSON(w, 200, dto)
|
||||
}
|
||||
@@ -121,7 +127,7 @@ func (s *Server) createSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) updateSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseScheduleID(r)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "bad schedule id")
|
||||
writeErr(w, 400, "计划编号无效")
|
||||
return
|
||||
}
|
||||
old, err := s.m.pg.GetTaskSchedule(id)
|
||||
@@ -145,7 +151,7 @@ func (s *Server) updateSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseScheduleID(r)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "bad schedule id")
|
||||
writeErr(w, 400, "计划编号无效")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.DeleteTaskSchedule(id); err != nil {
|
||||
@@ -158,7 +164,7 @@ func (s *Server) deleteSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) setScheduleEnabled(w http.ResponseWriter, r *http.Request, enabled bool) {
|
||||
id, err := parseScheduleID(r)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "bad schedule id")
|
||||
writeErr(w, 400, "计划编号无效")
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetTaskScheduleEnabled(id, enabled); err != nil {
|
||||
@@ -185,7 +191,7 @@ func (s *Server) resumeSchedule(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) runScheduleNow(w http.ResponseWriter, r *http.Request) {
|
||||
id, err := parseScheduleID(r)
|
||||
if err != nil {
|
||||
writeErr(w, 400, "bad schedule id")
|
||||
writeErr(w, 400, "计划编号无效")
|
||||
return
|
||||
}
|
||||
item, err := s.m.pg.GetTaskSchedule(id)
|
||||
|
||||
@@ -41,7 +41,7 @@ function statusText(s: TaskSchedule) {
|
||||
error: "错误",
|
||||
paused: "已暂停",
|
||||
} as Record<string, string>
|
||||
)[s.status] ?? s.status
|
||||
)[s.status] ?? "未知状态"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,73 @@ function formatTime(value?: string) {
|
||||
return value ? new Date(value).toLocaleString("zh-CN") : "—";
|
||||
}
|
||||
|
||||
function formatDateValue(value?: string) {
|
||||
if (!value) return "未设置";
|
||||
const [year, month, day] = value.split("-");
|
||||
return year && month && day ? `${year}年${Number(month)}月${Number(day)}日` : value;
|
||||
}
|
||||
|
||||
function formatClock(value?: string) {
|
||||
return value ? value.slice(0, 5) : "未设置";
|
||||
}
|
||||
|
||||
function periodLabel(item: TaskSchedule) {
|
||||
if (item.schedule_type === "once") {
|
||||
return item.end_date
|
||||
? `一次性 · ${formatDateValue(item.run_date)} 至 ${formatDateValue(item.end_date)}`
|
||||
: `一次性 · ${formatDateValue(item.run_date)}`;
|
||||
}
|
||||
const selected = item.weekdays.map((day) => weekdays[day - 1]).filter(Boolean);
|
||||
return `每周 · ${selected.length ? selected.join("、") : "未选择星期"}`;
|
||||
}
|
||||
|
||||
function windowLabel(item: TaskSchedule) {
|
||||
const start = formatClock(item.start_time);
|
||||
const end = formatClock(item.end_time);
|
||||
if (item.schedule_type === "once") {
|
||||
const startText = `${formatDateValue(item.run_date)} ${start}`;
|
||||
if (item.end_date) return `${startText} 至 ${formatDateValue(item.end_date)} ${item.end_time ? end : "24:00"}`;
|
||||
if (!item.end_time) return `${startText} 起持续运行`;
|
||||
return `${startText} 至 ${end}${end <= start ? "(次日)" : ""}`;
|
||||
}
|
||||
if (!item.end_time) return `${start} 起持续运行`;
|
||||
return `${start} 至 ${end}${end <= start ? "(次日)" : ""}`;
|
||||
}
|
||||
|
||||
function timezoneLabel(item: TaskSchedule) {
|
||||
return item.timezone_mode === "beijing" ? "中国北京时间" : `系统时区${item.timezone ? `(${item.timezone})` : ""}`;
|
||||
}
|
||||
|
||||
function nextWindowLabel(value?: string, timezone?: string) {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
const timeZone = timezone && timezone !== "Local" ? timezone : undefined;
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone,
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false,
|
||||
}).format(new Date(value));
|
||||
} catch {
|
||||
return formatTime(value);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleErrorText(value?: string) {
|
||||
if (!value) return "";
|
||||
return value
|
||||
.replaceAll("end_date", "截止日期")
|
||||
.replaceAll("run_date", "开始日期")
|
||||
.replaceAll("start_time", "开始时间")
|
||||
.replaceAll("end_time", "结束时间")
|
||||
.replaceAll("weekdays", "星期")
|
||||
.replaceAll("timezone_mode", "时区")
|
||||
.replaceAll("schedule_type", "规则");
|
||||
}
|
||||
|
||||
function statusVariant(status: TaskSchedule["status"]): "default" | "destructive" | "secondary" {
|
||||
if (status === "running") return "default";
|
||||
if (status === "error") return "destructive";
|
||||
@@ -321,80 +388,104 @@ export default function SchedulesPage() {
|
||||
);
|
||||
} else {
|
||||
scheduleContent = (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="overflow-hidden rounded-lg border bg-card">
|
||||
{items.map((item) => (
|
||||
<Card key={item.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div
|
||||
key={item.id}
|
||||
className="grid gap-4 border-b p-4 last:border-b-0 lg:grid-cols-[minmax(190px,1.1fr)_minmax(280px,1.6fr)_minmax(260px,1.4fr)_auto]"
|
||||
>
|
||||
<div className="grid content-start gap-2">
|
||||
<div className="flex items-start justify-between gap-3 lg:block">
|
||||
<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 className="font-medium">{item.name}</div>
|
||||
<div className="text-muted-foreground text-xs">计划 #{item.id}</div>
|
||||
</div>
|
||||
<Badge variant={statusVariant(item.status)}>{statusText(item)}</Badge>
|
||||
<Badge className="lg:mt-2" 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 className="text-muted-foreground text-xs">{timezoneLabel(item)}</div>
|
||||
</div>
|
||||
<div className="grid content-start gap-1.5 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">运行周期:</span>
|
||||
{periodLabel(item)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">时间窗口:</span>
|
||||
{windowLabel(item)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">绑定任务:</span>
|
||||
{item.task_ids.length
|
||||
? `${item.task_ids.length} 个(${item.task_ids.map((id) => `#${id}`).join("、")})`
|
||||
: "无"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid content-start gap-1.5 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">下一次开始:</span>
|
||||
{nextWindowLabel(item.next_start, item.timezone)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">下一次结束:</span>
|
||||
{nextWindowLabel(item.next_end, item.timezone)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground">运行次数:</span>
|
||||
{item.run_count ?? 0} 次{item.last_run_at ? ` · 最近 ${formatTime(item.last_run_at)}` : ""}
|
||||
</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}` : ""}
|
||||
{item.last_error ? ` · ${scheduleErrorText(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,
|
||||
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,
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
编辑
|
||||
</div>
|
||||
<div className="flex flex-wrap content-start gap-2 lg:justify-end">
|
||||
<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>
|
||||
{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 size="sm" variant="outline" onClick={() => void action(item.id, "resume")}>
|
||||
<Play data-icon="inline-start" />
|
||||
启用
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => void remove(item.id)}>
|
||||
<Trash2 data-icon="inline-start" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -734,6 +734,8 @@ export interface TaskSchedule {
|
||||
last_error?: string;
|
||||
next_start?: string;
|
||||
next_end?: string;
|
||||
run_count?: number;
|
||||
last_run_at?: string;
|
||||
task_ids: number[];
|
||||
history?: Array<{
|
||||
id: number;
|
||||
|
||||
Reference in new issue
Block a user