定时任务订阅模式

This commit is contained in:
吉鑫升 committed 2026-05-13 14:59:27 +08:00
1 parent 839386f0b4
commit aa034f8669
14 files changed
+1143 -98

No files matched your search

@@ -2,10 +2,12 @@ import { apiFetch } from "@/core/api";
import { getBackendBaseURL } from "@/core/config";
import type {
PublishedScheduledTask,
ScheduledTask,
ScheduledTaskDeliveryProfile,
ScheduledTaskResultFileContent,
ScheduledTaskRun,
ScheduledTaskSubscription,
SchedulerThread,
} from "./types";
@@ -214,3 +216,57 @@ export async function triggerScheduledTask(taskId: string) {
}),
);
}
export async function listPublishedScheduledTasks(params: { limit?: number; offset?: number } = {}) {
const query = new URLSearchParams();
if (params.limit != null) query.set("limit", String(params.limit));
if (params.offset != null) query.set("offset", String(params.offset));
const suffix = query.toString();
return parseResponse<PublishedScheduledTask[]>(
await apiFetch(`${baseUrl()}/published${suffix ? `?${suffix}` : ""}`),
);
}
export async function publishScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/publish`, {
method: "POST",
}),
);
}
export async function unpublishScheduledTask(taskId: string) {
return parseResponse<ScheduledTask>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/unpublish`, {
method: "POST",
}),
);
}
export async function subscribeScheduledTask(taskId: string, notifyElement: boolean) {
return parseResponse<ScheduledTaskSubscription>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notify_element: notifyElement }),
}),
);
}
export async function updateScheduledTaskSubscription(taskId: string, notifyElement: boolean) {
return parseResponse<ScheduledTaskSubscription>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ notify_element: notifyElement }),
}),
);
}
export async function unsubscribeScheduledTask(taskId: string) {
return parseResponse<{ success: boolean }>(
await apiFetch(`${baseUrl()}/${encodeURIComponent(taskId)}/subscribe`, {
method: "DELETE",
}),
);
}
@@ -7,13 +7,19 @@ import {
getSchedulerThread,
getScheduledTaskDeliveryProfile,
getScheduledTaskRun,
listPublishedScheduledTasks,
listScheduledTaskRuns,
listScheduledTasks,
pauseScheduledTask,
publishScheduledTask,
resumeScheduledTask,
subscribeScheduledTask,
triggerScheduledTask,
unpublishScheduledTask,
unsubscribeScheduledTask,
updateScheduledTaskDeliveryProfile,
updateScheduledTask,
updateScheduledTaskSubscription,
} from "./api";
export function useSchedulerThread() {
@@ -58,6 +64,15 @@ export function useScheduledTaskDeliveryProfile() {
});
}
export function usePublishedScheduledTasks(enabled: boolean = true) {
return useQuery({
queryKey: ["scheduled-tasks", "published"],
queryFn: () => listPublishedScheduledTasks({ limit: 100 }),
enabled,
refetchOnWindowFocus: false,
});
}
export function useScheduledTaskActions() {
const queryClient = useQueryClient();
const invalidate = () =>
@@ -107,5 +122,27 @@ export function useScheduledTaskActions() {
queryKey: ["scheduled-tasks", "delivery-profile"],
}),
}),
publish: useMutation({
mutationFn: publishScheduledTask,
onSuccess: invalidate,
}),
unpublish: useMutation({
mutationFn: unpublishScheduledTask,
onSuccess: invalidate,
}),
subscribe: useMutation({
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
subscribeScheduledTask(taskId, notifyElement),
onSuccess: invalidate,
}),
updateSubscription: useMutation({
mutationFn: ({ taskId, notifyElement }: { taskId: string; notifyElement: boolean }) =>
updateScheduledTaskSubscription(taskId, notifyElement),
onSuccess: invalidate,
}),
unsubscribe: useMutation({
mutationFn: unsubscribeScheduledTask,
onSuccess: invalidate,
}),
};
}
@@ -1,3 +1,14 @@
export type ScheduledTaskRole = "owner" | "subscriber";
export type ScheduledTaskSubscription = {
id: string;
task_id: string;
user_id: string;
notify_element: boolean;
created_at?: string | null;
updated_at?: string | null;
};
export type ScheduledTask = {
task_id: string;
name: string;
@@ -12,6 +23,20 @@ export type ScheduledTask = {
source_agent_name?: string | null;
execution_agent_name?: string | null;
scheduler_thread_id: string;
published?: boolean;
published_at?: string | null;
role?: ScheduledTaskRole;
_subscription?: ScheduledTaskSubscription | null;
};
export type PublishedScheduledTask = {
task_id: string;
name: string;
prompt_summary: string;
schedule_text: string;
timezone: string;
published_at?: string | null;
owner_user_id: string;
};
export type SchedulerThread = {
+305 -84
View File
@@ -2,9 +2,15 @@
import { useEffect, useMemo, useState } from "react";
import {
BellOff,
BellRing,
Download,
Edit3,
Eye,
Globe,
Heart,
HeartOff,
Lock,
Pause,
Play,
Plus,
@@ -21,6 +27,8 @@ import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
@@ -30,11 +38,13 @@ import {
import { useAgents } from "@/core/agents";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
type ScheduledTask,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
useScheduledTasks,
usePublishedScheduledTasks,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
@@ -217,6 +227,9 @@ export default function ScheduledTasksPage() {
const { data: deliveryProfile } = useScheduledTaskDeliveryProfile();
const { agents } = useAgents();
const actions = useScheduledTaskActions();
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
const { data: published = [], isLoading: publishedLoading } =
usePublishedScheduledTasks(activeTab === "plaza");
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [elementUserId, setElementUserId] = useState("");
const [editing, setEditing] = useState<ScheduledTask | null>(null);
@@ -224,6 +237,8 @@ export default function ScheduledTasksPage() {
const [form, setForm] = useState(emptyForm);
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(DEFAULT_SCHEDULE_CONFIG);
const [runsTimeFilter, setRunsTimeFilter] = useState<"all" | "today" | "7d" | "30d">("all");
const [subscribeTarget, setSubscribeTarget] = useState<PublishedScheduledTask | null>(null);
const [subscribeNotify, setSubscribeNotify] = useState(false);
const sortedTasks = useMemo(
() =>
@@ -377,95 +392,232 @@ export default function ScheduledTasksPage() {
保存
</Button>
</div>
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<TabsList className="w-full">
<TabsTrigger value="mine" className="flex-1">我的任务</TabsTrigger>
<TabsTrigger value="plaza" className="flex-1">广场</TabsTrigger>
</TabsList>
</Tabs>
</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}
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">
<div className="truncate font-medium">{task.name}</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone}
{activeTab === "mine" ? (
<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) => {
const isOwner = task.role !== "subscriber";
const isSubscriber = task.role === "subscriber";
const notifyOn = !!task._subscription?.notify_element;
return (
<button
key={task.task_id}
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">
<div className="flex items-center gap-2">
<div className="truncate font-medium">{task.name}</div>
{isSubscriber && (
<Badge variant="outline" className="shrink-0 text-xs">
<Heart className="size-3" />收藏
</Badge>
)}
{isOwner && task.published && (
<Badge variant="outline" className="shrink-0 text-xs">
<Globe className="size-3" />已发布
</Badge>
)}
</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>下次执行:{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">
{isOwner ? (
<>
<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();
task.enabled
? actions.pause.mutate(task.task_id)
: actions.resume.mutate(task.task_id);
}}
>
{task.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<Button
size="icon"
variant="outline"
title="立即执行"
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行");
}}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
title={task.published ? "取消发布" : "发布到广场"}
onClick={async (event) => {
event.stopPropagation();
try {
if (task.published) {
await actions.unpublish.mutateAsync(task.task_id);
toast.success("已取消发布(已订阅者保留)");
} else {
await actions.publish.mutateAsync(task.task_id);
toast.success("已发布到广场");
}
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}}
>
{task.published ? <Lock className="size-4" /> : <Globe className="size-4" />}
</Button>
<Button
size="icon"
variant="destructive"
title="删除"
onClick={(event) => {
event.stopPropagation();
actions.remove.mutate(task.task_id);
}}
>
<Trash2 className="size-4" />
</Button>
</>
) : (
<>
<Button
size="sm"
variant="outline"
title={notifyOn ? "关闭通知" : "开启通知"}
onClick={async (event) => {
event.stopPropagation();
try {
await actions.updateSubscription.mutateAsync({
taskId: task.task_id,
notifyElement: !notifyOn,
});
toast.success(notifyOn ? "已关闭 Element 通知" : "已开启 Element 通知");
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}}
>
{notifyOn ? <BellRing className="size-4" /> : <BellOff className="size-4" />}
{notifyOn ? "通知中" : "未通知"}
</Button>
<Button
size="sm"
variant="outline"
title="取消收藏"
onClick={async (event) => {
event.stopPropagation();
try {
await actions.unsubscribe.mutateAsync(task.task_id);
toast.success("已取消收藏");
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
}}
>
<HeartOff className="size-4" />取消收藏
</Button>
</>
)}
</div>
</button>
);
})}
</div>
) : (
<div className="flex flex-col gap-3 p-4 pt-0">
{publishedLoading && (
<div className="text-muted-foreground text-sm">正在加载广场任务...</div>
)}
{!publishedLoading && published.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
广场上还没有发布的任务。
</div>
)}
{published.map((item) => {
const subscribed = tasks.some((t) => t.task_id === item.task_id);
return (
<div key={item.task_id} className="bg-card rounded-md border p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">{item.name}</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{item.schedule_text} · {item.timezone}
</div>
</div>
{subscribed && (
<Badge variant="secondary" className="shrink-0 text-xs">
<Heart className="size-3" />已收藏
</Badge>
)}
</div>
<div className="text-muted-foreground mt-3 line-clamp-3 text-xs whitespace-pre-wrap">
{item.prompt_summary || "(无摘要)"}
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="sm"
variant={subscribed ? "outline" : "default"}
disabled={subscribed}
onClick={() => {
setSubscribeTarget(item);
setSubscribeNotify(false);
}}
>
<Heart className="size-4" />
{subscribed ? "已收藏" : "收藏到我的任务"}
</Button>
</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>下次执行:{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();
task.enabled
? actions.pause.mutate(task.task_id)
: actions.resume.mutate(task.task_id);
}}
>
{task.enabled ? <Pause className="size-4" /> : <Play className="size-4" />}
</Button>
<Button
size="icon"
variant="outline"
title="立即执行"
onClick={(event) => {
event.stopPropagation();
actions.trigger.mutate(task.task_id);
toast.success("已触发执行");
}}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="destructive"
title="删除"
onClick={(event) => {
event.stopPropagation();
actions.remove.mutate(task.task_id);
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</button>
))}
</div>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
@@ -566,6 +718,75 @@ export default function ScheduledTasksPage() {
</div>
</WorkspaceBody>
<Dialog
open={!!subscribeTarget}
onOpenChange={(open) => {
if (!open) {
setSubscribeTarget(null);
setSubscribeNotify(false);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>收藏到我的任务</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<div>
<div className="font-medium">{subscribeTarget?.name}</div>
<div className="text-muted-foreground mt-1 text-xs">
{subscribeTarget?.schedule_text} · {subscribeTarget?.timezone}
</div>
</div>
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
{subscribeTarget?.prompt_summary || "(无摘要)"}
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div>
<div className="text-sm font-medium">同步接收 Element 通知</div>
<div className="text-muted-foreground mt-1 text-xs">
开启后,每次执行结果将复制一份发送到你设置的 Element 用户。
{!deliveryProfile?.element_user_id && (
<span className="text-destructive"> 你尚未配置 Element 用户 ID。</span>
)}
</div>
</div>
<Switch checked={subscribeNotify} onCheckedChange={setSubscribeNotify} />
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setSubscribeTarget(null);
setSubscribeNotify(false);
}}
>
取消
</Button>
<Button
onClick={async () => {
if (!subscribeTarget) return;
try {
await actions.subscribe.mutateAsync({
taskId: subscribeTarget.task_id,
notifyElement: subscribeNotify,
});
toast.success("已收藏到我的任务");
setSubscribeTarget(null);
setSubscribeNotify(false);
setActiveTab("mine");
} catch (error) {
toast.error(error instanceof Error ? error.message : "收藏失败");
}
}}
>
确认收藏
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={creating || !!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>