510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useRef, useState } from "react";
|
||
import { useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
Download,
|
||
Edit3,
|
||
Eye,
|
||
Pause,
|
||
Play,
|
||
RefreshCw,
|
||
Search,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||
import { toast } from "sonner";
|
||
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Button } from "@/components/ui/button";
|
||
import { useTheme } from "@/contexts/ThemeContext";
|
||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Input } from "@/components/ui/input";
|
||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
WorkspaceBody,
|
||
WorkspaceContainer,
|
||
WorkspaceHeader,
|
||
} from "@/components/workspace/workspace-container";
|
||
import {
|
||
buildCron,
|
||
DEFAULT_SCHEDULE_CONFIG,
|
||
parseCron,
|
||
ScheduleBuilder,
|
||
type ScheduleConfig,
|
||
statusLabel,
|
||
statusVariant,
|
||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||
import {
|
||
downloadScheduledTaskRun,
|
||
type ScheduledTask,
|
||
useAdminScheduledTaskActions,
|
||
useAdminScheduledTaskRuns,
|
||
useAllScheduledTasks,
|
||
} from "@/core/scheduled-tasks";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
function formatDate(value?: string | null) {
|
||
return value ? new Date(value).toLocaleString() : "暂无";
|
||
}
|
||
|
||
/** Render a creator-like identifier without the email domain part. */
|
||
function displayUsername(value?: string | null): string {
|
||
const raw = (value ?? "").trim();
|
||
if (!raw) return "";
|
||
const at = raw.indexOf("@");
|
||
return at === -1 ? raw : raw.slice(0, at);
|
||
}
|
||
|
||
const emptyForm = {
|
||
name: "",
|
||
timezone: "Asia/Shanghai",
|
||
prompt: "",
|
||
};
|
||
|
||
export default function AdminScheduledTasksPage() {
|
||
const queryClient = useQueryClient();
|
||
const { theme } = useTheme();
|
||
const navigate = useNavigate();
|
||
const [searchParams] = useSearchParams();
|
||
// ?task= carries the task to re-select when returning from a run detail.
|
||
const initialTaskId = searchParams.get("task");
|
||
const [searchInput, setSearchInput] = useState("");
|
||
const [search, setSearch] = useState("");
|
||
const { data: tasks = [], isLoading } = useAllScheduledTasks({ search });
|
||
const actions = useAdminScheduledTaskActions();
|
||
|
||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
|
||
initialTaskId,
|
||
);
|
||
// One-shot scroll target after a back-navigation.
|
||
const pendingScrollRef = useRef<string | null>(initialTaskId);
|
||
const [editing, setEditing] = useState<ScheduledTask | null>(null);
|
||
const [form, setForm] = useState(emptyForm);
|
||
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(
|
||
DEFAULT_SCHEDULE_CONFIG,
|
||
);
|
||
|
||
const sortedTasks = useMemo(
|
||
() =>
|
||
[...tasks].sort((a, b) =>
|
||
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
|
||
),
|
||
[tasks],
|
||
);
|
||
const selectedTask =
|
||
sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
|
||
const { data: runs = [], isLoading: runsLoading } =
|
||
useAdminScheduledTaskRuns(selectedTaskId);
|
||
|
||
useEffect(() => {
|
||
if (!selectedTaskId && sortedTasks[0]) {
|
||
setSelectedTaskId(sortedTasks[0].task_id);
|
||
}
|
||
if (
|
||
selectedTaskId &&
|
||
!sortedTasks.some((task) => task.task_id === selectedTaskId)
|
||
) {
|
||
setSelectedTaskId(sortedTasks[0]?.task_id ?? null);
|
||
}
|
||
}, [selectedTaskId, sortedTasks]);
|
||
|
||
const openEdit = (task: ScheduledTask) => {
|
||
setEditing(task);
|
||
setForm({
|
||
name: task.name,
|
||
timezone: task.timezone,
|
||
prompt: task.prompt,
|
||
});
|
||
setScheduleConfig(
|
||
parseCron(task.cron_expr) ??
|
||
parseCron(task.schedule_text) ??
|
||
DEFAULT_SCHEDULE_CONFIG,
|
||
);
|
||
};
|
||
|
||
const closeDialog = () => {
|
||
setEditing(null);
|
||
setForm(emptyForm);
|
||
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
|
||
};
|
||
|
||
const saveTask = async () => {
|
||
if (!editing) return;
|
||
try {
|
||
await actions.update.mutateAsync({
|
||
taskId: editing.task_id,
|
||
data: {
|
||
name: form.name,
|
||
schedule: buildCron(scheduleConfig),
|
||
timezone: form.timezone,
|
||
prompt: form.prompt,
|
||
},
|
||
});
|
||
toast.success("任务已更新");
|
||
closeDialog();
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||
}
|
||
};
|
||
|
||
const toggleEnabled = async (task: ScheduledTask) => {
|
||
try {
|
||
await actions.update.mutateAsync({
|
||
taskId: task.task_id,
|
||
data: { enabled: !task.enabled },
|
||
});
|
||
toast.success(task.enabled ? "任务已暂停" : "任务已启用");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "操作失败");
|
||
}
|
||
};
|
||
|
||
const removeTask = async (task: ScheduledTask) => {
|
||
if (!window.confirm(`确认删除任务「${task.name}」?该操作不可撤销。`)) return;
|
||
try {
|
||
await actions.remove.mutateAsync(task.task_id);
|
||
toast.success("任务已删除");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||
}
|
||
};
|
||
|
||
const triggerTask = async (task: ScheduledTask) => {
|
||
setSelectedTaskId(task.task_id);
|
||
try {
|
||
await actions.trigger.mutateAsync(task.task_id);
|
||
await queryClient.invalidateQueries({
|
||
queryKey: ["scheduled-tasks", "admin", task.task_id, "runs"],
|
||
});
|
||
await queryClient.invalidateQueries({
|
||
queryKey: ["scheduled-tasks", "admin", "list"],
|
||
});
|
||
toast.success("已触发执行");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "触发失败");
|
||
}
|
||
};
|
||
|
||
const downloadRun = async (runId: string) => {
|
||
try {
|
||
await downloadScheduledTaskRun(runId);
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "下载失败");
|
||
}
|
||
};
|
||
|
||
const deleteRun = async (runId: string) => {
|
||
if (!window.confirm("确认删除这条执行记录?该操作不可撤销。")) return;
|
||
try {
|
||
await actions.removeRun.mutateAsync(runId);
|
||
toast.success("执行记录已删除");
|
||
} catch (error) {
|
||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||
}
|
||
};
|
||
|
||
return (
|
||
<WorkspaceContainer>
|
||
<WorkspaceHeader />
|
||
<WorkspaceBody>
|
||
<div className="grid size-full min-h-0 grid-cols-1 grid-rows-[minmax(0,1fr)_minmax(0,1fr)] gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)] xl:grid-rows-1">
|
||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||
<CardHeader className="gap-3">
|
||
<div className="min-w-0">
|
||
<CardTitle>定时任务管理</CardTitle>
|
||
<p className="text-muted-foreground mt-1 text-sm">
|
||
管理员视图:查看、编辑、删除全部用户的定时任务
|
||
</p>
|
||
</div>
|
||
<form
|
||
className="flex min-w-0 gap-2"
|
||
onSubmit={(event) => {
|
||
event.preventDefault();
|
||
setSearch(searchInput.trim());
|
||
}}
|
||
>
|
||
<Input
|
||
className="min-w-0"
|
||
value={searchInput}
|
||
onChange={(event) => setSearchInput(event.target.value)}
|
||
placeholder="按任务名称搜索"
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
variant={theme === "dark" ? "default" : "outline"}
|
||
>
|
||
<Search className="size-4" />
|
||
搜索
|
||
</Button>
|
||
</form>
|
||
</CardHeader>
|
||
<CardContent className="min-h-0 flex-1 p-0">
|
||
<ScrollArea className="size-full">
|
||
<div className="flex flex-col gap-3 p-4 pt-0">
|
||
{isLoading && (
|
||
<div className="text-muted-foreground text-sm">
|
||
正在加载任务...
|
||
</div>
|
||
)}
|
||
{!isLoading && sortedTasks.length === 0 && (
|
||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||
没有匹配的定时任务。
|
||
</div>
|
||
)}
|
||
{sortedTasks.map((task) => (
|
||
<button
|
||
key={task.task_id}
|
||
ref={(el) => {
|
||
if (el && pendingScrollRef.current === task.task_id) {
|
||
pendingScrollRef.current = null;
|
||
el.scrollIntoView({ block: "center" });
|
||
}
|
||
}}
|
||
className={cn(
|
||
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
|
||
selectedTaskId === task.task_id &&
|
||
"border-primary bg-accent",
|
||
)}
|
||
onClick={() => setSelectedTaskId(task.task_id)}
|
||
>
|
||
<div className="flex items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium">
|
||
{task.name}
|
||
</div>
|
||
<div className="text-muted-foreground mt-1 truncate text-xs">
|
||
{task.schedule_text} · {task.timezone}
|
||
</div>
|
||
</div>
|
||
<Badge variant={task.enabled ? "default" : "secondary"}>
|
||
{task.enabled ? "启用" : "暂停"}
|
||
</Badge>
|
||
</div>
|
||
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
|
||
<div>
|
||
创建者:
|
||
{displayUsername(task.creator_email) ||
|
||
task.creator_user_id ||
|
||
"未知"}
|
||
</div>
|
||
<div>下次执行:{formatDate(task.next_run_at)}</div>
|
||
<div>上次执行:{formatDate(task.last_run_at)}</div>
|
||
<div>最近状态:{statusLabel(task.last_status)}</div>
|
||
</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<Button
|
||
size="icon"
|
||
variant="outline"
|
||
title="编辑"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
openEdit(task);
|
||
}}
|
||
>
|
||
<Edit3 className="size-4" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="outline"
|
||
title={task.enabled ? "暂停" : "启用"}
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void toggleEnabled(task);
|
||
}}
|
||
>
|
||
{task.enabled ? (
|
||
<Pause className="size-4" />
|
||
) : (
|
||
<Play className="size-4" />
|
||
)}
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="outline"
|
||
title="立即执行"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void triggerTask(task);
|
||
}}
|
||
>
|
||
<RefreshCw className="size-4" />
|
||
</Button>
|
||
<Button
|
||
size="icon"
|
||
variant="destructive"
|
||
title="删除"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
void removeTask(task);
|
||
}}
|
||
>
|
||
<Trash2 className="size-4" />
|
||
</Button>
|
||
</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card className="flex min-h-0 min-w-0 flex-col">
|
||
<CardHeader className="gap-2">
|
||
<CardTitle>执行记录</CardTitle>
|
||
<p className="text-muted-foreground min-w-0 truncate text-sm">
|
||
{selectedTask ? selectedTask.name : "请选择左侧任务"}
|
||
</p>
|
||
</CardHeader>
|
||
<CardContent className="min-h-0 flex-1 p-0">
|
||
<ScrollArea className="size-full">
|
||
<div className="flex flex-col gap-3 p-4 pt-0">
|
||
{runsLoading && (
|
||
<div className="text-muted-foreground text-sm">
|
||
正在加载执行记录...
|
||
</div>
|
||
)}
|
||
{!selectedTask && (
|
||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||
请先选择一个定时任务。
|
||
</div>
|
||
)}
|
||
{!runsLoading && selectedTask && runs.length === 0 && (
|
||
<div className="text-muted-foreground rounded-md border p-4 text-sm">
|
||
这个任务还没有执行记录。
|
||
</div>
|
||
)}
|
||
{runs.map((run) => (
|
||
<div
|
||
key={run.id}
|
||
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
|
||
>
|
||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="truncate font-medium">
|
||
{run.result?.title || formatDate(run.scheduled_for)}
|
||
</div>
|
||
<div className="text-muted-foreground mt-1 break-words text-xs">
|
||
计划:{formatDate(run.scheduled_for)} · 开始:
|
||
{formatDate(run.started_at)}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{(run.retry_count ?? 0) > 0 && (
|
||
<Badge variant="outline" className="text-xs">
|
||
已重试 {run.retry_count}
|
||
</Badge>
|
||
)}
|
||
<Badge variant={statusVariant(run.status)}>
|
||
{statusLabel(run.status)}
|
||
</Badge>
|
||
</div>
|
||
</div>
|
||
{run.error && (
|
||
<div className="text-destructive mt-3 line-clamp-2 text-sm">
|
||
{run.error}
|
||
</div>
|
||
)}
|
||
<div className="mt-4 flex flex-wrap gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={() =>
|
||
navigate(
|
||
`/page/workspace/scheduled/runs/${run.id}?from=admin&task=${encodeURIComponent(run.task_id)}`,
|
||
)
|
||
}
|
||
>
|
||
<Eye className="size-4" />
|
||
查看结果
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
disabled={!run.result}
|
||
onClick={() => void downloadRun(run.id)}
|
||
>
|
||
<Download className="size-4" />
|
||
下载结果
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
variant="destructive"
|
||
onClick={() => void deleteRun(run.id)}
|
||
>
|
||
<Trash2 className="size-4" />
|
||
删除
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</ScrollArea>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</WorkspaceBody>
|
||
|
||
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
|
||
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col gap-4 overflow-hidden sm:max-w-2xl">
|
||
<DialogHeader className="shrink-0">
|
||
<DialogTitle>编辑定时任务</DialogTitle>
|
||
</DialogHeader>
|
||
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pr-1">
|
||
<div className="flex flex-col gap-3">
|
||
<Input
|
||
value={form.name}
|
||
onChange={(event) =>
|
||
setForm((value) => ({ ...value, name: event.target.value }))
|
||
}
|
||
placeholder="任务名称"
|
||
/>
|
||
<div className="flex flex-col gap-1">
|
||
<p className="text-muted-foreground text-xs">执行频率</p>
|
||
<ScheduleBuilder
|
||
value={scheduleConfig}
|
||
onChange={setScheduleConfig}
|
||
/>
|
||
</div>
|
||
<Input
|
||
value={form.timezone}
|
||
onChange={(event) =>
|
||
setForm((value) => ({
|
||
...value,
|
||
timezone: event.target.value,
|
||
}))
|
||
}
|
||
placeholder="时区,例如:Asia/Shanghai"
|
||
/>
|
||
<Textarea
|
||
rows={10}
|
||
className="field-sizing-fixed min-h-40 max-h-48 resize-y overflow-y-auto"
|
||
value={form.prompt}
|
||
onChange={(event) =>
|
||
setForm((value) => ({ ...value, prompt: event.target.value }))
|
||
}
|
||
placeholder="任务 Prompt"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<DialogFooter className="shrink-0">
|
||
<Button variant="outline" onClick={closeDialog}>
|
||
取消
|
||
</Button>
|
||
<Button onClick={saveTask}>保存</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</WorkspaceContainer>
|
||
);
|
||
}
|