- 排行榜改为按天预计算快照(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>
1174 lines
52 KiB
TypeScript
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>
|
|
);
|
|
}
|