优化计划任务列表展示

This commit is contained in:
ji ruo committed 2026-10-10 00:30:51 +08:00
1 parent 98321d8262
commit 30db68a86a
4 files changed
+203 -84

No files matched your search

+31 -11
View File
@@ -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
View File
@@ -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)
+156 -65
View File
@@ -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>
);
+2
View File
@@ -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;