Files
deerflow-code/frontend-web/src/pages/ScheduledTasksPage.tsx
T
AdministratorandClaude Opus 4.8 e7c745a512 feat: 排行榜日快照分析 + 数据库连接池/索引调优
- 排行榜改为按天预计算快照(admin_leaderboard_daily_stats),请求只读取合并,重统计移至后台调度;过去日不可变、当天 TTL 刷新,避免在线聚合长时间占用数据库
- 为 runs/tool_call_metrics/llm_call_metrics/threads_meta 增加面向时间段+用户聚合的复合索引(model __table_args__ + migration 20260608_01)
- 连接池可调:DatabaseConfig 新增 max_overflow / pool_timeout,接入 postgres/mysql 引擎;config.yaml 上调 pool_size=20/max_overflow=20
- 新增手动执行的索引脚本 scripts/sql/leaderboard_indexes_mysql.sql(幂等,适用于已存在数据、create_all 不补索引且 alembic 关闭的部署)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 00:52:24 +08:00

1174 lines
52 KiB
TypeScript

"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
AlertCircle,
BellOff,
BellRing,
CheckCircle2,
Download,
Edit3,
Eye,
Globe,
Heart,
HeartOff,
Loader2,
Lock,
Pause,
Play,
Plus,
RefreshCw,
TagsIcon,
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 { 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 { 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,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import { TagChipList } from "@/components/workspace/tags/tag-chip";
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
import { useAgents } from "@/core/agents";
import { useModels } from "@/core/models/hooks";
import {
downloadScheduledTaskRun,
type PublishedScheduledTask,
type ScheduledTask,
type ScheduledTaskExecutionContext,
useHtmlPageFavoriteActions,
useHtmlPageFavorites,
useScheduledTaskActions,
useScheduledTaskDeliveryProfile,
useScheduledTaskRuns,
useScheduledTasks,
usePublishedScheduledTasks,
} from "@/core/scheduled-tasks";
import {
buildCron,
DEFAULT_SCHEDULE_CONFIG,
parseCron,
ScheduleBuilder,
type ScheduleConfig,
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import { useTheme } from "@/contexts/ThemeContext";
import { desensitize } from "@/lib/desensitize";
import { cn } from "@/lib/utils";
/** 成功 toast:仅暗色模式,浅色走 Sonner 默认 */
const successToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#106455] !bg-[#052934] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <CheckCircle2 className="size-4 shrink-0 text-[#2FD69A]" />,
} as const;
/** 失败 toast:仅暗色模式,浅色走 Sonner 默认 */
const errorToastDarkOptions = {
classNames: {
toast:
"!rounded-[2px] !border !border-[#7F1A2A] !bg-[#311629] !text-[#FFFFFF] shadow-md",
title: "!text-[#FFFFFF]",
description: "!text-[#FFFFFF]",
},
icon: <AlertCircle className="size-4 shrink-0 text-[#F5222D]" />,
} as const;
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: "",
description: "",
schedule: "",
timezone: "Asia/Shanghai",
prompt: "",
execution_agent_name: "",
// Two independent output switches (may both be on): require a Markdown file
// and/or render an HTML page. The form no longer splits into "普通任务 / HTML
// 页面" tabs.
require_markdown: false,
require_html: false,
// HTML-only extra fields, shown when ``require_html`` is on. The main
// ``prompt`` above doubles as the page requirement, so there is no separate
// page-prompt field. ``html_model_name`` empty means "use the first available
// model" — never a hardcoded GLM-5.
html_negative_prompt: "",
html_reference_favorite_id: "",
html_model_name: "",
};
/** 覆盖 Textarea 默认 field-sizing-content,避免输入过多时撑破弹窗 */
const SCHEDULED_TASK_TEXTAREA_CLASS =
"field-sizing-fixed resize-y overflow-y-auto";
const DESCRIPTION_TEXTAREA_ROWS = 4;
const PROMPT_TEXTAREA_ROWS = 10;
export default function ScheduledTasksPage() {
const queryClient = useQueryClient();
const { theme } = useTheme();
const darkToastOpts =
theme === "dark"
? { success: successToastDarkOptions, error: errorToastDarkOptions }
: { success: undefined, error: undefined };
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// When returning from a run-detail page the task id is carried in ?task=,
// so the list re-selects (and scrolls to) the task the user was viewing.
const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState("");
const [tagIds, setTagIds] = useState<string[]>([]);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
const { data: tasks = [], isLoading } = useScheduledTasks({ search, tagIds });
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>(initialTaskId);
// One-shot scroll target: scroll the task into view after a back-navigation.
const pendingScrollRef = useRef<string | null>(initialTaskId);
const [elementUserId, setElementUserId] = useState("");
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
const [form, setForm] = useState(emptyForm);
const dialogOpen = creating || !!editing;
const { data: htmlFavorites = [] } = useHtmlPageFavorites(dialogOpen);
const htmlFavoriteActions = useHtmlPageFavoriteActions();
const { models: availableModels } = useModels({ enabled: dialogOpen });
const handleDeleteFavorite = async (favoriteId: string) => {
try {
await htmlFavoriteActions.remove.mutateAsync(favoriteId);
// Clear the selection if the removed favorite was the chosen reference.
setForm((value) =>
value.html_reference_favorite_id === favoriteId
? { ...value, html_reference_favorite_id: "" }
: value,
);
toast.success("收藏已删除", darkToastOpts.success);
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败", darkToastOpts.error);
}
};
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 [tagPickerTask, setTagPickerTask] = useState<ScheduledTask | null>(null);
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) => {
// Newest task first. Fall back to next_run_at so tasks that lack a
// created_at (older rows / subscribed tasks) still order consistently.
const aKey = a.created_at ?? a.next_run_at ?? "";
const bKey = b.created_at ?? b.next_run_at ?? "";
return String(bKey).localeCompare(String(aKey));
}),
[tasks],
);
const selectedTask = sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const selectedTaskCanEditRuns = !!selectedTask && selectedTask.role !== "subscriber";
const { data: runs = [], isLoading: runsLoading } = useScheduledTaskRuns(selectedTaskId);
const filteredRuns = useMemo(() => {
if (runsTimeFilter === "all") return runs;
const now = Date.now();
const ms = runsTimeFilter === "today"
? Date.now() - new Date().setHours(0, 0, 0, 0)
: runsTimeFilter === "7d"
? 7 * 86400_000
: 30 * 86400_000;
const cutoff = now - ms;
return runs.filter((r) => r.scheduled_for && new Date(r.scheduled_for).getTime() >= cutoff);
}, [runs, runsTimeFilter]);
useEffect(() => {
setElementUserId(deliveryProfile?.element_user_id ?? "");
}, [deliveryProfile?.element_user_id]);
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 openCreate = () => {
setCreating(true);
setEditing(null);
setForm(emptyForm);
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const openEdit = (task: ScheduledTask) => {
setCreating(false);
setEditing(task);
const html = task.execution_context?.html_config ?? {};
setForm({
name: task.name,
description: task.description ?? "",
schedule: task.schedule_text,
timezone: task.timezone,
// The main prompt doubles as the HTML page requirement; legacy HTML tasks
// stored that under ``html_config.page_prompt``, so prefer it on edit.
prompt: html.page_prompt || task.prompt,
execution_agent_name: task.execution_agent_name ?? "",
require_markdown: !!task.execution_context?.require_markdown,
// Backward compatible: legacy HTML tasks only carry ``task_kind``.
require_html:
!!task.execution_context?.require_html ||
task.execution_context?.task_kind === "html_page",
html_negative_prompt: html.negative_prompt ?? "",
html_reference_favorite_id: html.reference_favorite_id ?? "",
html_model_name: html.model_name ?? "",
});
// Prefer the canonical cron_expr over the user-typed schedule_text: the
// latter may be natural language ("每天9点") that parseCron can't read,
// which is what made the dialog default back to "每天9点 → 每小时0分".
setScheduleConfig(
parseCron(task.cron_expr) ??
parseCron(task.schedule_text) ??
DEFAULT_SCHEDULE_CONFIG,
);
};
const closeDialog = () => {
setCreating(false);
setEditing(null);
setForm(emptyForm);
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const isHtmlTask = form.require_html;
// The model actually shown/used: the user's choice, else the first configured
// model. Never a hardcoded GLM-5 — it follows config order and the selection.
const effectiveHtmlModel = form.html_model_name || availableModels[0]?.name || "";
// Build the execution_context for the current form. ``require_html`` and
// ``require_markdown`` are independent toggles (both may be on). HTML tasks
// persist their extra config under ``html_config`` and reuse the main prompt
// as the page requirement; ``task_kind`` is kept in sync only for backward
// compatibility with older readers.
const buildExecutionContext = (): ScheduledTaskExecutionContext => {
if (!isHtmlTask) {
return { require_html: false, task_kind: "agent", html_config: undefined };
}
return {
require_html: true,
task_kind: "html_page",
html_config: {
// The main prompt doubles as the page requirement.
page_prompt: form.prompt.trim() || null,
negative_prompt: form.html_negative_prompt.trim() || null,
reference_favorite_id: form.html_reference_favorite_id || null,
// Send the user's selection; fall back to the first configured model,
// else null (backend then uses the system default). No hardcoded GLM-5.
model_name: form.html_model_name.trim() || availableModels[0]?.name || null,
},
};
};
const saveTask = async () => {
try {
const cronSchedule = buildCron(scheduleConfig);
const executionContext = buildExecutionContext();
// The (non-null) prompt column always needs something useful; HTML tasks
// may leave it sparse, so fall back to a generic instruction.
const effectivePrompt =
form.prompt.trim() || (isHtmlTask ? "生成 HTML 页面" : form.prompt);
if (creating) {
const task = await actions.create.mutateAsync({
name: form.name || "定时任务",
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone || "Asia/Shanghai",
prompt: effectivePrompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
execution_context: executionContext,
});
setSelectedTaskId(task.task_id);
toast.success("任务已创建", darkToastOpts.success);
} else if (editing) {
await actions.update.mutateAsync({
taskId: editing.task_id,
data: {
name: form.name,
description: form.description.trim() || null,
schedule: cronSchedule,
timezone: form.timezone,
prompt: effectivePrompt,
execution_agent_name: form.execution_agent_name || null,
require_markdown: form.require_markdown,
execution_context: executionContext,
},
});
toast.success("任务已更新", darkToastOpts.success);
}
closeDialog();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存失败",
darkToastOpts.error,
);
}
};
const handleDownload = async (runId: string) => {
try {
await downloadScheduledTaskRun(runId);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "下载失败",
darkToastOpts.error,
);
}
};
const handleDeleteRun = async (runId: string) => {
try {
await actions.removeRun.mutateAsync(runId);
toast.success("执行记录已删除", darkToastOpts.success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "删除失败",
darkToastOpts.error,
);
}
};
const handleTriggerTask = async (task: ScheduledTask) => {
setSelectedTaskId(task.task_id);
setRunsTimeFilter("all");
try {
await actions.trigger.mutateAsync(task.task_id);
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", task.task_id, "runs"],
});
await queryClient.invalidateQueries({
queryKey: ["scheduled-tasks", "list"],
});
toast.success("已触发执行", darkToastOpts.success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "触发失败",
darkToastOpts.error,
);
}
};
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-[420px_minmax(0,1fr)] xl:grid-rows-1">
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-3">
<div className="flex items-center justify-between gap-3">
<div className="min-w-0">
<CardTitle>定时任务</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
选择任务后,在右侧查看全部执行记录
</p>
</div>
<Button size="sm" onClick={openCreate}>
<Plus className="size-4" />
新建
</Button>
</div>
<div className="flex gap-2">
<Input
value={elementUserId}
onChange={(event) => setElementUserId(event.target.value)}
placeholder="Element 用户 ID,可选"
/>
<Button
variant="outline"
className="dark:bg-[#0D2D54] dark:hover:!bg-[#2580FF] dark:hover:text-white"
onClick={async () => {
try {
await actions.updateDeliveryProfile.mutateAsync({
element_user_id: elementUserId || null,
});
toast.success("Element 配置已保存", darkToastOpts.success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "保存失败",
darkToastOpts.error,
);
}
}}
>
保存
</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>
{activeTab === "mine" && (
<TagSearchBar
targetType="scheduled_task"
onSearchChange={setSearch}
tagIds={tagIds}
onTagIdsChange={setTagIds}
/>
)}
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
{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;
const isRunningNow =
task.last_status === "running" ||
task.last_status === "pending";
return (
<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="font-medium leading-snug break-all">
{desensitize(task.name)}
</div>
<div className="mt-1 flex flex-wrap items-center gap-1">
{isRunningNow && (
<Badge
variant="secondary"
className="border-primary/30 bg-primary/10 text-primary shrink-0 text-xs"
>
<Loader2 className="size-3 animate-spin" />
正在执行
</Badge>
)}
{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>
)}
{(task.execution_context?.require_html ||
task.execution_context?.task_kind === "html_page") && (
<Badge
variant="outline"
className="shrink-0 border-sky-500/30 bg-sky-500/10 text-xs text-sky-600 dark:text-sky-400"
>
HTML 页面
</Badge>
)}
</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{desensitize(task.schedule_text ?? "")} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"} className="shrink-0">
{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 className="flex items-center gap-1">
<span>最近状态:{statusLabel(task.last_status)}</span>
{isRunningNow && (
<Loader2 className="text-primary size-3 animate-spin" />
)}
</div>
{(task.creator_email || task.creator_user_id) && (
<div className="truncate">
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id}
</div>
)}
</div>
{(task.tags?.length ?? 0) > 0 && (
<TagChipList tags={task.tags!} className="mt-3" />
)}
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="icon"
variant="outline"
title="编辑标签"
onClick={(event) => {
event.stopPropagation();
setTagPickerTask(task);
}}
>
<TagsIcon className="size-4" />
</Button>
{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();
void handleTriggerTask(task);
}}
>
<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(
"已取消发布(已订阅者保留)",
darkToastOpts.success,
);
} else {
await actions.publish.mutateAsync(task.task_id);
toast.success("已发布到广场", darkToastOpts.success);
}
} catch (error) {
toast.error(
error instanceof Error ? error.message : "操作失败",
darkToastOpts.error,
);
}
}}
>
{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 通知",
darkToastOpts.success,
);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "操作失败",
darkToastOpts.error,
);
}
}}
>
{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("已取消收藏", darkToastOpts.success);
} catch (error) {
toast.error(
error instanceof Error ? error.message : "操作失败",
darkToastOpts.error,
);
}
}}
>
<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">{desensitize(item.name)}</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{desensitize(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 ? desensitize(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>
);
})}
</div>
)}
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 min-w-0 flex-col">
<CardHeader className="gap-2">
<div className="flex min-w-0 items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2">
<CardTitle>执行记录</CardTitle>
{selectedTask && (
<Button
size="sm"
variant="outline"
className="h-7 gap-1 px-2 text-xs"
title="在新标签打开该任务的公开页"
onClick={() => {
const url = `${window.location.origin}${window.location.pathname}#/public/scheduled-tasks/${encodeURIComponent(selectedTask.task_id)}`;
window.open(url, "_blank", "noopener,noreferrer");
}}
>
<Globe className="size-3.5" />
公开页
</Button>
)}
</div>
<Select value={runsTimeFilter} onValueChange={(v) => setRunsTimeFilter(v as typeof runsTimeFilter)}>
<SelectTrigger className="h-7 w-28 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
<SelectItem value="all">全部时间</SelectItem>
<SelectItem value="today">今天</SelectItem>
<SelectItem value="7d">近 7 天</SelectItem>
<SelectItem value="30d">近 30 天</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-muted-foreground min-w-0 truncate text-sm">
{selectedTask ? desensitize(selectedTask.name) : "请选择左侧任务"}
</p>
{selectedTask && (selectedTask.creator_email || selectedTask.creator_user_id) && (
<p className="text-muted-foreground text-xs">
创建者:
{displayUsername(selectedTask.creator_email) ||
selectedTask.creator_user_id}
</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>
)}
{!runsLoading && selectedTask && filteredRuns.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
{runs.length === 0 ? "这个任务还没有执行记录。" : "当前时间范围内没有执行记录。"}
</div>
)}
{!selectedTask && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
请先选择一个定时任务。
</div>
)}
{filteredRuns.map((run) => {
const runRunning =
run.status === "running" || run.status === "pending";
return (
<div
key={run.id}
className={cn(
"rounded-md border p-4 transition-colors hover:bg-accent/40",
runRunning && "border-primary/40 bg-primary/5",
)}
>
<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 ? desensitize(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>
<Badge
variant={statusVariant(run.status)}
className={cn(runRunning && "border-primary/30 bg-primary/10 text-primary")}
>
{runRunning && <Loader2 className="size-3 animate-spin" />}
{statusLabel(run.status)}
</Badge>
</div>
{run.error && (
<div className="text-destructive mt-3 line-clamp-2 text-sm">
{desensitize(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=mine&task=${encodeURIComponent(run.task_id)}`,
)
}
>
<Eye className="size-4" />
查看结果
</Button>
<Button
size="sm"
variant="outline"
onClick={() => handleDownload(run.id)}
disabled={!run.result}
>
<Download className="size-4" />
下载结果
</Button>
{selectedTaskCanEditRuns && run.can_edit !== false && (
<Button
size="sm"
variant="destructive"
onClick={() => handleDeleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
)}
</div>
</div>
);
})}
</div>
</ScrollArea>
</CardContent>
</Card>
</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">{desensitize(subscribeTarget?.name ?? "")}</div>
<div className="text-muted-foreground mt-1 text-xs">
{desensitize(subscribeTarget?.schedule_text ?? "")} · {subscribeTarget?.timezone}
</div>
</div>
<div className="text-muted-foreground line-clamp-4 text-sm whitespace-pre-wrap">
{subscribeTarget?.prompt_summary ? desensitize(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("已收藏到我的任务", darkToastOpts.success);
setSubscribeTarget(null);
setSubscribeNotify(false);
setActiveTab("mine");
} catch (error) {
toast.error(
error instanceof Error ? error.message : "收藏失败",
darkToastOpts.error,
);
}
}}
>
确认收藏
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={creating || !!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="min-w-0 shrink-0">
<DialogTitle>{creating ? "新建定时任务" : "编辑定时任务"}</DialogTitle>
</DialogHeader>
<div className="flex min-h-0 max-h-[70vh] min-w-0 flex-col gap-3 overflow-y-auto pr-1">
<Input
value={form.name}
onChange={(event) =>
setForm((value) => ({ ...value, name: event.target.value }))
}
placeholder="任务名称"
/>
<Textarea
rows={DESCRIPTION_TEXTAREA_ROWS}
className={cn(
SCHEDULED_TASK_TEXTAREA_CLASS,
"min-h-[4.5rem] max-h-28",
)}
value={form.description}
onChange={(event) =>
setForm((value) => ({ ...value, description: 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"
/>
<Select
value={form.execution_agent_name || "__default__"}
onValueChange={(v) =>
setForm((value) => ({
...value,
execution_agent_name: v === "__default__" ? "" : v,
}))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择执行智能体" />
</SelectTrigger>
<SelectContent
position="popper"
className="max-h-[40vh] w-[max(var(--radix-select-trigger-width),28rem)] max-w-[min(90vw,40rem)]"
>
<SelectItem value="__default__">默认助手(不指定)</SelectItem>
{agents.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<span className="block whitespace-normal break-words">
{desensitize(agent.name)}
{agent.description ? ` — ${desensitize(agent.description)}` : ""}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder={
isHtmlTask
? "任务 / 页面生成要求,例如:检索新能源车最新舆情,生成适合领导快速浏览的态势页"
: "任务 Prompt"
}
/>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 Markdown 文件</p>
<p className="text-muted-foreground text-xs">
开启后会提示大模型生成 Markdown(.md) 文件;系统会校验本次是否生成了 .md 文件且文件正文超过 100 字,未达到则记为失败。
</p>
</div>
<Switch
checked={form.require_markdown}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_markdown: v }))
}
/>
</div>
<div className="flex items-center justify-between rounded-md border p-3">
<div className="min-w-0 pr-3">
<p className="text-sm font-medium">必须生成 HTML 页面</p>
<p className="text-muted-foreground text-xs">
开启后会先用智能体检索整理真实数据,再渲染为完整 HTML 页面(可在执行记录中预览)。可与“必须生成 Markdown”同时开启。
</p>
</div>
<Switch
checked={form.require_html}
onCheckedChange={(v) =>
setForm((value) => ({ ...value, require_html: v }))
}
/>
</div>
{isHtmlTask && (
<>
<Textarea
className="min-h-16"
value={form.html_negative_prompt}
onChange={(event) =>
setForm((value) => ({ ...value, html_negative_prompt: event.target.value }))
}
placeholder="HTML 限制条件(可选),例如:不要使用深色背景,不要出现外部链接脚本"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">参考收藏页面(可选)</p>
<div className="flex flex-col gap-1 rounded-md border p-1">
<button
type="button"
onClick={() =>
setForm((value) => ({ ...value, html_reference_favorite_id: "" }))
}
className={cn(
"rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
!form.html_reference_favorite_id && "bg-accent",
)}
>
不参考(默认)
</button>
{htmlFavorites.map((fav) => {
const selected = form.html_reference_favorite_id === fav.id;
return (
<div key={fav.id} className="flex items-center gap-1">
<button
type="button"
onClick={() =>
setForm((value) => ({ ...value, html_reference_favorite_id: fav.id }))
}
className={cn(
"min-w-0 flex-1 truncate rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent/60",
selected && "bg-accent",
)}
title={fav.title}
>
{fav.title}
</button>
<Button
type="button"
size="icon"
variant="ghost"
className="size-7 shrink-0 text-muted-foreground hover:text-destructive"
onClick={() => handleDeleteFavorite(fav.id)}
disabled={htmlFavoriteActions.remove.isPending}
title="删除该收藏"
>
<Trash2 className="size-4" />
</Button>
</div>
);
})}
{htmlFavorites.length === 0 && (
<div className="text-muted-foreground px-2 py-1.5 text-xs">
暂无收藏页面。可在执行记录的 HTML 预览中收藏页面后在此引用。
</div>
)}
</div>
</div>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">生成模型</p>
<Select
value={effectiveHtmlModel}
onValueChange={(v) =>
setForm((value) => ({ ...value, html_model_name: v }))
}
>
<SelectTrigger className="w-full min-w-0 max-w-full overflow-hidden [&>span]:min-w-0 [&>span]:flex-1 [&>span]:truncate">
<SelectValue placeholder="选择生成模型" />
</SelectTrigger>
<SelectContent
position="popper"
className="w-[var(--radix-select-trigger-width)] max-w-[var(--radix-select-trigger-width)]"
>
{availableModels.map((model) => (
<SelectItem key={model.id || model.name} value={model.name}>
<span className="block truncate">{model.display_name || model.name}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<p className="text-muted-foreground text-xs">
执行流程:智能体运用自身检索技能整理真实数据 → 选定模型渲染为完整 HTML → 自动安全清洗与结构校验 → 可在执行记录中预览。
</p>
</>
)}
</div>
<DialogFooter className="shrink-0">
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
<Button onClick={saveTask}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{tagPickerTask && (
<TagPickerDialog
open={!!tagPickerTask}
onOpenChange={(open) => !open && setTagPickerTask(null)}
targetType="scheduled_task"
targetId={tagPickerTask.task_id}
currentTags={tagPickerTask.tags ?? []}
/>
)}
</WorkspaceContainer>
);
}