feat: 定时任务运行详情执行过程视图 + 管理员下载删除 + MySQL 迁移修复
定时任务:
- 运行详情页新增「执行过程」标签页,按 agent_run_id 拉取该次运行的
agent 消息(GET /scheduled-tasks/runs/{id}/messages),运行中每 4s
自动刷新,可观察卡住的任务停在哪一步
- 详情页「返回」按钮回到来源列表(普通/管理员)并重新选中原任务
- 管理员可下载、删除任意用户的执行记录(DELETE /runs/{id} 加管理员兜底)
MySQL 兼容修复:
- 合并迁移 20260518_06 汇合 ai-writing 与 thread-shares 两个 head
- 20260517_02 改用 SQLAlchemy Core 构造,规避保留字 key 的 1064 报错
- list_messages_by_run 各事件存储补 user_id 形参以保持接口一致
测试: 新增 test_scheduled_task_admin_run_delete.py
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
449703a6f0
commit
e3cde9be25
18 files changed
+534
-57
No files matched your search
@@ -8,6 +8,7 @@ import type {
|
||||
ScheduledTaskResultFileContent,
|
||||
ScheduledTaskRun,
|
||||
ScheduledTaskRunFileUpdateResponse,
|
||||
ScheduledTaskRunMessagesResponse,
|
||||
ScheduledTaskSubscription,
|
||||
SchedulerThread,
|
||||
} from "./types";
|
||||
@@ -79,6 +80,13 @@ export async function deleteScheduledTaskRun(runId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Agent messages for a run's underlying agent run — used to inspect a stuck/running run. */
|
||||
export async function getScheduledTaskRunMessages(runId: string) {
|
||||
return parseResponse<ScheduledTaskRunMessagesResponse>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/messages`),
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduledTaskRunDownloadUrl(runId: string) {
|
||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
getSchedulerThread,
|
||||
getScheduledTaskDeliveryProfile,
|
||||
getScheduledTaskRun,
|
||||
getScheduledTaskRunMessages,
|
||||
listAllScheduledTasks,
|
||||
listPublishedScheduledTasks,
|
||||
listScheduledTaskRuns,
|
||||
@@ -62,12 +63,34 @@ export function useScheduledTaskRuns(taskId?: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** A run still in flight — used to drive live polling of the run/messages. */
|
||||
function runIsLive(status?: string | null): boolean {
|
||||
return status === "running" || status === "pending";
|
||||
}
|
||||
|
||||
export function useScheduledTaskRun(runId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId],
|
||||
queryFn: () => getScheduledTaskRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
// Keep refreshing while the run is still executing so the detail page
|
||||
// reflects status / result changes without a manual reload.
|
||||
refetchInterval: (query) => (runIsLive(query.state.data?.status) ? 5000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Agent messages for a run; polls every 4s while the run is still executing. */
|
||||
export function useScheduledTaskRunMessages(
|
||||
runId?: string | null,
|
||||
options: { live?: boolean } = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId, "messages"],
|
||||
queryFn: () => getScheduledTaskRunMessages(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: options.live ? 4000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,6 +147,12 @@ export function useAdminScheduledTaskActions() {
|
||||
mutationFn: adminDeleteScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
// The DELETE /runs/{id} route is admin-aware, so the same call deletes
|
||||
// any user's run when the caller is an admin.
|
||||
removeRun: useMutation({
|
||||
mutationFn: deleteScheduledTaskRun,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
trigger: useMutation({
|
||||
mutationFn: adminTriggerScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
|
||||
@@ -107,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = {
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
};
|
||||
|
||||
/** One agent message produced by a scheduled run's underlying agent run. */
|
||||
export type ScheduledTaskRunMessage = {
|
||||
seq: number;
|
||||
event_type: string;
|
||||
category: string;
|
||||
// string for human input; object (LangChain message dump) for ai/tool.
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRunMessagesResponse = {
|
||||
messages: ScheduledTaskRunMessage[];
|
||||
has_more: boolean;
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
Download,
|
||||
Edit3,
|
||||
Eye,
|
||||
Pause,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
Search,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -41,6 +43,7 @@ import {
|
||||
statusVariant,
|
||||
} from "@/components/workspace/scheduled-tasks/schedule-builder";
|
||||
import {
|
||||
downloadScheduledTaskRun,
|
||||
type ScheduledTask,
|
||||
useAdminScheduledTaskActions,
|
||||
useAdminScheduledTaskRuns,
|
||||
@@ -68,12 +71,19 @@ const emptyForm = {
|
||||
|
||||
export default function AdminScheduledTasksPage() {
|
||||
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>(null);
|
||||
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>(
|
||||
@@ -174,6 +184,24 @@ export default function AdminScheduledTasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
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 />
|
||||
@@ -221,6 +249,12 @@ export default function AdminScheduledTasksPage() {
|
||||
{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 &&
|
||||
@@ -371,13 +405,30 @@ export default function AdminScheduledTasksPage() {
|
||||
variant="outline"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/page/workspace/scheduled/runs/${run.id}`,
|
||||
`/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>
|
||||
))}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
|
||||
import { useParams, useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
getScheduledTaskRunFileContent,
|
||||
type ScheduledTaskResultFileContent,
|
||||
type ScheduledTaskRun,
|
||||
type ScheduledTaskRunMessage,
|
||||
updateScheduledTaskRunFile,
|
||||
useScheduledTaskActions,
|
||||
useScheduledTaskRun,
|
||||
useScheduledTaskRunMessages,
|
||||
} from "@/core/scheduled-tasks";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
|
||||
|
||||
type EditState = { draft: string; saving: boolean };
|
||||
|
||||
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
|
||||
function messageText(content: unknown): string {
|
||||
if (content == null) return "";
|
||||
if (typeof content === "string") return content;
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") return part;
|
||||
if (part && typeof part === "object" && "text" in part) {
|
||||
return String((part as { text?: unknown }).text ?? "");
|
||||
}
|
||||
return "";
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (typeof content === "object" && "content" in content) {
|
||||
return messageText((content as { content?: unknown }).content);
|
||||
}
|
||||
return JSON.stringify(content, null, 2);
|
||||
}
|
||||
|
||||
const EXECUTION_ROLE: Record<string, { label: string; tone: string }> = {
|
||||
"llm.human.input": { label: "触发输入", tone: "bg-muted text-foreground" },
|
||||
"llm.ai.response": { label: "智能体", tone: "bg-primary/10 text-primary" },
|
||||
"llm.tool.result": { label: "工具结果", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
|
||||
};
|
||||
|
||||
/** One agent message rendered inside the execution-process tab. */
|
||||
function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
|
||||
const role = EXECUTION_ROLE[message.event_type] ?? {
|
||||
label: message.event_type,
|
||||
tone: "bg-muted text-foreground",
|
||||
};
|
||||
const raw = message.content;
|
||||
const obj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
|
||||
const text = messageText(raw);
|
||||
const toolName = typeof obj?.name === "string" ? obj.name : null;
|
||||
const toolCalls = Array.isArray(obj?.tool_calls)
|
||||
? (obj!.tool_calls as Array<Record<string, unknown>>)
|
||||
: [];
|
||||
const isAi = message.event_type === "llm.ai.response";
|
||||
|
||||
return (
|
||||
<div className="rounded-md border">
|
||||
<div className="flex items-center gap-2 border-b px-3 py-1.5">
|
||||
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
|
||||
{role.label}
|
||||
{toolName ? ` · ${toolName}` : ""}
|
||||
</span>
|
||||
{message.created_at && (
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{new Date(message.created_at).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{text ? (
|
||||
isAi ? (
|
||||
<MarkdownContent className="max-w-none" content={text} isLoading={false} rehypePlugins={[]} />
|
||||
) : (
|
||||
<pre className="whitespace-pre-wrap break-words text-xs">{text}</pre>
|
||||
)
|
||||
) : (
|
||||
!toolCalls.length && <div className="text-muted-foreground text-xs">(无文本内容)</div>
|
||||
)}
|
||||
{toolCalls.length > 0 && (
|
||||
<div className="mt-2 flex flex-col gap-1">
|
||||
{toolCalls.map((call, index) => (
|
||||
<div key={index} className="bg-muted rounded px-2 py-1 text-xs">
|
||||
<span className="font-medium">调用工具:{String(call.name ?? "未知")}</span>
|
||||
{call.args != null && (
|
||||
<pre className="mt-1 whitespace-pre-wrap break-words">
|
||||
{typeof call.args === "string" ? call.args : JSON.stringify(call.args, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ScheduledTaskRunDetailPage() {
|
||||
const navigate = useNavigate();
|
||||
const { run_id } = useParams();
|
||||
const [searchParams] = useSearchParams();
|
||||
const actions = useScheduledTaskActions();
|
||||
const { data: run, isLoading } = useScheduledTaskRun(run_id);
|
||||
const result = run?.result ?? null;
|
||||
// A run still executing — drives live polling of its agent messages.
|
||||
const runLive = run?.status === "running" || run?.status === "pending";
|
||||
const hasExecution = !!run?.agent_run_id;
|
||||
const { data: messagesData, isLoading: messagesLoading } = useScheduledTaskRunMessages(
|
||||
hasExecution ? run_id : null,
|
||||
{ live: runLive },
|
||||
);
|
||||
const runMessages = messagesData?.messages ?? [];
|
||||
|
||||
// Back navigation returns to the originating list (admin vs. mine) and
|
||||
// re-selects the task the user came from (carried in ?task=).
|
||||
const handleBack = useCallback(() => {
|
||||
const base =
|
||||
searchParams.get("from") === "admin"
|
||||
? "/page/workspace/admin/scheduled-tasks"
|
||||
: "/page/workspace/scheduled/tasks";
|
||||
const taskId = run?.task_id ?? searchParams.get("task");
|
||||
navigate(taskId ? `${base}?task=${encodeURIComponent(taskId)}` : base);
|
||||
}, [navigate, run?.task_id, searchParams]);
|
||||
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
|
||||
const [filesLoading, setFilesLoading] = useState(false);
|
||||
const [showMeta, setShowMeta] = useState(false);
|
||||
@@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
try {
|
||||
await actions.removeRun.mutateAsync(run.id);
|
||||
toast.success("执行记录已删除");
|
||||
navigate("/page/workspace/scheduled/tasks");
|
||||
handleBack();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "删除失败");
|
||||
}
|
||||
@@ -281,9 +388,9 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
|
||||
执行信息
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
|
||||
<ListChecks className="size-3" />
|
||||
返回记录
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleBack}>
|
||||
<ChevronLeft className="size-3" />
|
||||
返回
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
|
||||
<Download className="size-3" />
|
||||
@@ -330,28 +437,45 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
{/* 内容区 */}
|
||||
{!result && run && (
|
||||
{run && !result && !hasExecution && (
|
||||
<div className="text-muted-foreground p-4 text-sm">
|
||||
这条记录暂时还没有结果,任务可能仍在执行中。
|
||||
</div>
|
||||
)}
|
||||
{result && (
|
||||
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
|
||||
{run && (result || hasExecution) && (
|
||||
<Tabs
|
||||
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
>
|
||||
<div className="border-b px-3 pt-1">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
{result && (
|
||||
<TabsTrigger value="body" className="text-xs">
|
||||
<FileText className="mr-1 size-3" />
|
||||
正文
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="files" className="text-xs">
|
||||
结果文件{fileCount > 0 && `(${fileCount})`}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{hasExecution && (
|
||||
<TabsTrigger value="execution" className="text-xs">
|
||||
<Activity className="mr-1 size-3" />
|
||||
执行过程{runMessages.length > 0 && `(${runMessages.length})`}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{result && (
|
||||
<TabsTrigger value="meta" className="text-xs">
|
||||
元数据
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
{result && (
|
||||
<>
|
||||
<TabsContent value="body" className="m-0 p-4">
|
||||
{result.format === "markdown" || result.format === "mixed" ? (
|
||||
<MarkdownContent
|
||||
@@ -471,6 +595,31 @@ export default function ScheduledTaskRunDetailPage() {
|
||||
{resultMetadata}
|
||||
</pre>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
{hasExecution && (
|
||||
<TabsContent value="execution" className="m-0 p-3">
|
||||
{runLive && (
|
||||
<div className="text-primary border-primary/30 bg-primary/5 mb-3 flex items-center gap-2 rounded-md border p-2 text-xs">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
任务执行中,下方消息每 4 秒自动刷新…
|
||||
</div>
|
||||
)}
|
||||
{messagesLoading && runMessages.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm">正在加载执行消息...</div>
|
||||
)}
|
||||
{!messagesLoading && runMessages.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-md border p-3 text-sm">
|
||||
暂无执行消息,任务可能还未开始执行。
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col gap-3">
|
||||
{runMessages.map((message) => (
|
||||
<ExecutionMessage key={message.seq} message={message} />
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
BellOff,
|
||||
BellRing,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
TagsIcon,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useRouteNavigate as useNavigate } from "@/core/route-base";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -84,6 +85,10 @@ const emptyForm = {
|
||||
|
||||
export default function ScheduledTasksPage() {
|
||||
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 [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
|
||||
@@ -94,7 +99,9 @@ export default function ScheduledTasksPage() {
|
||||
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
|
||||
const { data: published = [], isLoading: publishedLoading } =
|
||||
usePublishedScheduledTasks(activeTab === "plaza");
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
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);
|
||||
@@ -300,6 +307,12 @@ export default function ScheduledTasksPage() {
|
||||
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",
|
||||
@@ -598,7 +611,11 @@ export default function ScheduledTasksPage() {
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/page/workspace/scheduled/runs/${run.id}?from=mine&task=${encodeURIComponent(run.task_id)}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
查看结果
|
||||
|
||||
Reference in new issue
Block a user