feat(web): split plan page from tasks (FE-6)
- Nav overview group: 首页 / 任务 / 计划 / Agent 会话; tasks page drops the plan tab - /console/tasks?tab=plan[&job=] redirects to /console/schedules[?job=] - Plan page: 自动任务 compact grouped list (switch, interval preset/custom, last result, countdown, failure reason, run now/skip/run records) and Agent 巡检 cards (goal, allowlist, budget, upstream/model, trigger, cost per run, recent sessions); split stats - Home plan band: two counts linking to #auto / #checks - Docs: console-ia §7.4a, glossary, schedules runbook; smoke routes Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
c3aaf901ad
commit
baf2d6ea04
22 files changed
+1030
-417
No files matched your search
@@ -413,3 +413,31 @@ export function useRunInvocationAudit(runId: string, enabled: boolean, version =
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 巡检触发的最近会话(trigger=schedule,createdBy = schedule:<定时任务 id>):
|
||||
* 计划页的巡检卡片、首页计划带按定时任务分组取费用、模型与最近结果。一次取最近 100 条。
|
||||
*/
|
||||
export function useScheduleAgentRuns() {
|
||||
return useQuery({
|
||||
queryKey: [...agentKeys.runs(undefined, "schedule"), "plan"],
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(RunPage, AGENT_BASE, { params: cleanParams({ trigger: "schedule", limit: 100 }), signal }),
|
||||
select: (page) => page.items,
|
||||
staleTime: 30 * TTL_SECOND,
|
||||
refetchInterval: 60 * TTL_SECOND,
|
||||
});
|
||||
}
|
||||
|
||||
/** 按定时任务 id 分组(新 → 旧,保持接口顺序) */
|
||||
export function groupRunsBySchedule(runs: AgentRun[]): Map<string, AgentRun[]> {
|
||||
const out = new Map<string, AgentRun[]>();
|
||||
for (const r of runs) {
|
||||
const id = r.createdBy?.startsWith("schedule:") ? r.createdBy.slice("schedule:".length) : null;
|
||||
if (!id) continue;
|
||||
const list = out.get(id);
|
||||
if (list) list.push(r);
|
||||
else out.set(id, [r]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -19,22 +19,21 @@ describe("控制台导航", () => {
|
||||
it("IA-3:5 组,按定稿清单逐项列出,路径唯一且都在 /console 下", () => {
|
||||
expect(NAV_GROUPS.map((g) => g.title)).toEqual(["概览", "资源", "订阅", "整理", "系统"]);
|
||||
expect(NAV_GROUPS.map((g) => g.items.map((i) => i.label))).toEqual([
|
||||
["首页", "任务", "Agent 会话"],
|
||||
["首页", "任务", "计划", "Agent 会话"],
|
||||
["站点", "RSS 订阅"],
|
||||
["订阅", "规则"],
|
||||
["下载面板", "整理记录", "文件管理", "媒体概览"],
|
||||
["设置", "审计", "运行状态"],
|
||||
]);
|
||||
// 定稿文字写"共 12 项",逐项清单实际是 14 项;以清单为准
|
||||
expect(NAV_ITEMS).toHaveLength(14);
|
||||
// IA-3 逐项清单 14 项 + FE-6 计划页
|
||||
expect(NAV_ITEMS).toHaveLength(15);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(NAV_ITEMS.length);
|
||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||
});
|
||||
|
||||
it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 定时任务 / 身份并入别处", () => {
|
||||
it("取消的入口不在侧栏:待处理走顶栏收件箱,引擎面板 / 身份并入别处", () => {
|
||||
const paths = NAV_ITEMS.map((i) => i.to);
|
||||
for (const p of ["/console/inbox", "/console/engine", "/console/schedules", "/console/identity"])
|
||||
expect(paths).not.toContain(p);
|
||||
for (const p of ["/console/inbox", "/console/engine", "/console/identity"]) expect(paths).not.toContain(p);
|
||||
expect(pageTitle("/console/inbox")).toBe("待处理");
|
||||
});
|
||||
|
||||
@@ -47,6 +46,7 @@ describe("控制台导航", () => {
|
||||
expect(findNavItem("/console/")?.label).toBe("首页");
|
||||
expect(findNavItem("/console/library/abc")?.label).toBe("媒体概览");
|
||||
expect(findNavItem("/console/tasks/r1")?.label).toBe("任务");
|
||||
expect(findNavItem("/console/schedules")?.label).toBe("计划");
|
||||
expect(findNavItem("/console/downloads")?.label).toBe("下载面板");
|
||||
expect(findNavItem("/console/downloads/plans")?.label).toBe("下载面板");
|
||||
expect(findNavItem("/console/downloads/abc")?.label).toBe("下载面板");
|
||||
@@ -83,7 +83,7 @@ describe("旧链接重定向", () => {
|
||||
|
||||
it("不相关的 query 不跳转", () => {
|
||||
expect(legacyRedirect("/console/tasks", "?kind=submitDownload")).toBeNull();
|
||||
expect(legacyRedirect("/console/tasks", "?tab=plan&job=trash.purge")).toBeNull();
|
||||
expect(legacyRedirect("/console/schedules", "?job=trash.purge")).toBeNull();
|
||||
expect(legacyRedirect("/console/tasks", "?tab=failed")).toBeNull();
|
||||
expect(legacyRedirect("/console/library", "?filter=unidentified")).toBeNull();
|
||||
expect(legacyRedirect("/console/subscriptions", "")).toBeNull();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Bot,
|
||||
CalendarClock,
|
||||
CalendarCog,
|
||||
Download,
|
||||
FileStack,
|
||||
FolderTree,
|
||||
@@ -34,7 +35,7 @@ export function consolePath(...segments: (string | number)[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 侧栏 5 组 14 项(IA-3 定稿清单;定稿文字写"共 12 项",以逐项清单为准,docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。
|
||||
* 侧栏 5 组 15 项(IA-3 定稿清单 14 项 + FE-6 从任务页拆出的"计划",docs/plan/00-first-version-plan.md §8):概览 → 资源 → 订阅 → 整理 → 系统。
|
||||
* 待处理不进侧栏,由顶栏收件箱打开;移动端抽屉用同一份表。
|
||||
*/
|
||||
export const NAV_GROUPS: NavGroup[] = [
|
||||
@@ -51,7 +52,13 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
to: "/console/tasks",
|
||||
label: "任务",
|
||||
icon: ListTodo,
|
||||
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里,含定时任务计划",
|
||||
description: "每次运行都是一条任务:定时、事件、手动、Agent 触发都在这里",
|
||||
},
|
||||
{
|
||||
to: "/console/schedules",
|
||||
label: "计划",
|
||||
icon: CalendarCog,
|
||||
description: "定时任务的开关、间隔和下次运行:自动任务与 Agent 巡检",
|
||||
},
|
||||
{
|
||||
to: "/console/agent",
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
type AgentCheckConfig,
|
||||
type AgentRun,
|
||||
CAPABILITY_CATALOG,
|
||||
type KnownCapabilityId,
|
||||
type ScheduledJob,
|
||||
} from "@umt/contracts";
|
||||
import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useDeleteSchedule } from "~/api/schedules.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { agentRunLabel, formatRelative } from "~/lib/labels.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { NextRun, useJobActions } from "./auto-task-list.tsx";
|
||||
import { IntervalSelect } from "./interval-select.tsx";
|
||||
import { CHECK_TRIGGER_LABEL, formatUsd, RUN_STATUS } from "./labels.ts";
|
||||
|
||||
// 计划页「Agent 巡检」:到点按目标模板发起一次 Agent 会话(kind=agent_check),一个巡检一张卡片。
|
||||
|
||||
/** 卡片上能力白名单最多列几个,其余折成"+N" */
|
||||
const MAX_CAPS = 6;
|
||||
|
||||
/** 会话摘要是 Markdown:列表里只取纯文字 */
|
||||
const plainSummary = (t: string) =>
|
||||
t
|
||||
.replace(/[#*`>_]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
const capTitle = (id: string) => CAPABILITY_CATALOG[id as KnownCapabilityId]?.title ?? id;
|
||||
|
||||
/** 默认上游(巡检没有单独的上游设置,跟随 Agent 设置里的默认上游) */
|
||||
export type DefaultUpstream = { name: string; model: string | null } | null;
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-meta text-muted-foreground">{label}</dt>
|
||||
<dd className="min-w-0 text-caption text-foreground">{children}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentCheckCard({
|
||||
job,
|
||||
now,
|
||||
runs,
|
||||
upstream,
|
||||
highlight,
|
||||
onEdit,
|
||||
onHistory,
|
||||
}: {
|
||||
job: ScheduledJob;
|
||||
now: number;
|
||||
/** 该巡检最近的会话(新 → 旧) */
|
||||
runs: AgentRun[];
|
||||
upstream: DefaultUpstream;
|
||||
highlight: boolean;
|
||||
onEdit: (job: ScheduledJob) => void;
|
||||
onHistory: (job: ScheduledJob) => void;
|
||||
}) {
|
||||
const a = useJobActions(job);
|
||||
const del = useDeleteSchedule();
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
if (highlight) ref.current?.scrollIntoView({ block: "center" });
|
||||
}, [highlight]);
|
||||
|
||||
const c = job.config as Partial<AgentCheckConfig>;
|
||||
const budget = c.budget ?? {};
|
||||
const allow = c.capabilityAllowlist ?? [];
|
||||
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
|
||||
const latest = runs[0] ?? null;
|
||||
const costs = runs.map((r) => r.usage.costUsd).filter((v): v is number => typeof v === "number");
|
||||
const avg = costs.length ? costs.reduce((s, v) => s + v, 0) / costs.length : null;
|
||||
const budgetParts = [
|
||||
budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : null,
|
||||
budget.maxDurationSec ? `${budget.maxDurationSec} 秒` : null,
|
||||
budget.maxTokens ? `${budget.maxTokens.toLocaleString("zh-CN")} tokens` : null,
|
||||
budget.maxCostUsd ? `${formatUsd(budget.maxCostUsd)} 上限` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return (
|
||||
<article
|
||||
ref={ref}
|
||||
id={`job-${job.id}`}
|
||||
aria-label={job.name}
|
||||
className={cn(
|
||||
"pg-slab grid min-w-0 scroll-mt-20 grid-cols-[minmax(0,1fr)] content-start gap-3 rounded-xl px-4 py-3.5",
|
||||
highlight && "ring-2 ring-ring/50",
|
||||
)}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-lead font-medium text-foreground" title={job.name}>
|
||||
{job.name}
|
||||
</h3>
|
||||
{job.description ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-meta text-muted-foreground">{job.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={`${job.enabled ? "停用" : "启用"}${job.name}`}
|
||||
checked={job.enabled && job.available}
|
||||
disabled={!job.available || a.update.isPending}
|
||||
onCheckedChange={a.setEnabled}
|
||||
/>
|
||||
</header>
|
||||
|
||||
<div className="rounded-tight border border-border/70 px-2.5 py-2">
|
||||
<p className="line-clamp-3 text-caption text-foreground" title={c.goalTemplate}>
|
||||
{c.goalTemplate ?? "—"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
|
||||
<Field label="能力">
|
||||
{allow.length ? (
|
||||
<span className="flex flex-wrap gap-1">
|
||||
{allow.slice(0, MAX_CAPS).map((id) => (
|
||||
<span
|
||||
key={id}
|
||||
title={id}
|
||||
className="rounded-full border border-border/70 px-1.5 py-px text-meta text-muted-foreground"
|
||||
>
|
||||
{capTitle(id)}
|
||||
</span>
|
||||
))}
|
||||
{allow.length > MAX_CAPS ? (
|
||||
<span
|
||||
className="px-1 text-meta text-muted-foreground"
|
||||
title={allow.slice(MAX_CAPS).map(capTitle).join("、")}
|
||||
>
|
||||
+{allow.length - MAX_CAPS}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
) : (
|
||||
"全部只读能力"
|
||||
)}
|
||||
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
|
||||
</Field>
|
||||
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
|
||||
<Field label="上游">
|
||||
{latest?.model ? (
|
||||
<span className="break-words">
|
||||
{latest.provider ? `${latest.provider} · ` : ""}
|
||||
{latest.model}
|
||||
</span>
|
||||
) : upstream ? (
|
||||
<span className="break-words">
|
||||
默认上游 {upstream.name}
|
||||
{upstream.model ? ` · ${upstream.model}` : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">未配置 Agent 上游</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="触发条件">
|
||||
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
|
||||
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
|
||||
</Field>
|
||||
<Field label="间隔">
|
||||
<IntervalSelect
|
||||
label={`${job.name}间隔`}
|
||||
schedule={job.schedule}
|
||||
disabled={!job.available}
|
||||
pending={a.update.isPending}
|
||||
onSave={a.setSchedule}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="下次">
|
||||
<span className="text-meta">
|
||||
<NextRun job={job} now={now} />
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="每次费用">
|
||||
{costs.length ? (
|
||||
<span className="tabular-nums">
|
||||
上次 {formatUsd(costs[0])}
|
||||
<span className="text-muted-foreground">
|
||||
{" "}
|
||||
· 近 {costs.length} 次平均 {formatUsd(avg)}
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">还没有会话</span>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="最近结果">
|
||||
<span className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
</span>
|
||||
</Field>
|
||||
</dl>
|
||||
|
||||
{job.lastStatus === "failed" && job.lastError ? (
|
||||
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||
<summary className="cursor-pointer truncate group-open:whitespace-normal">失败原因:{job.lastError}</summary>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
{runs.length ? (
|
||||
<section aria-label="最近会话" className="grid min-w-0 gap-1 border-t border-border/70 pt-2.5">
|
||||
<h4 className="text-meta text-muted-foreground">最近会话</h4>
|
||||
<ul className="grid min-w-0 grid-cols-[minmax(0,1fr)] gap-1">
|
||||
{runs.slice(0, 3).map((r) => {
|
||||
const s = agentRunLabel(r.status);
|
||||
return (
|
||||
<li key={r.id} className="min-w-0">
|
||||
<Link
|
||||
to={consolePath("agent", r.id)}
|
||||
className="flex min-w-0 items-center gap-2 rounded-tight px-1 py-0.5 text-meta text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
||||
>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
|
||||
{plainSummary(r.summary ?? r.goal)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums">{formatUsd(r.usage.costUsd)}</span>
|
||||
<span className="shrink-0" title={r.createdAt}>
|
||||
{formatRelative(r.finishedAt ?? r.createdAt)}
|
||||
</span>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-1.5">
|
||||
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
|
||||
{a.runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
|
||||
立即执行
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" disabled={!a.canSkip} onClick={a.skipNext}>
|
||||
<SkipForward />
|
||||
跳过本次
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onHistory(job)}>
|
||||
<History />
|
||||
运行记录
|
||||
</Button>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{job.customized ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="恢复默认"
|
||||
title="恢复默认"
|
||||
disabled={a.update.isPending}
|
||||
onClick={a.reset}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button size="icon-sm" variant="ghost" aria-label="编辑巡检" title="编辑巡检" onClick={() => onEdit(job)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
{!job.builtin ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="icon-sm" variant="ghost" aria-label="删除巡检" title="删除巡检">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除「${job.name}」?`}
|
||||
description="运行记录一并删除,已产生的 Agent 会话保留。"
|
||||
confirmLabel="删除巡检"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
del.mutateAsync({ id: job.id }).then(
|
||||
() => toast.success("已删除"),
|
||||
(e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts";
|
||||
import { History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { IntervalSelect } from "./interval-select.tsx";
|
||||
import { countdown, formatMs, GROUP_LABEL, RUN_STATUS, scheduleStreamPath, TASK_GROUPS } from "./labels.ts";
|
||||
|
||||
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组的紧凑列表。
|
||||
|
||||
/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */
|
||||
export function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
||||
if (job.runningSince)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-foreground">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
运行中 · 已 {formatMs(Math.max(0, now - new Date(job.runningSince).getTime()))}
|
||||
</span>
|
||||
);
|
||||
if (job.runRequestedAt) return <span className="text-foreground">排队中</span>;
|
||||
if (!job.available) return <span>未配置</span>;
|
||||
if (!job.enabled) return <span>已停用</span>;
|
||||
if (!job.nextRunAt) return <span>待调度</span>;
|
||||
return <span className="tabular-nums text-foreground">{countdown(job.nextRunAt, now)}</span>;
|
||||
}
|
||||
|
||||
/** 开关、间隔、立即执行、跳过本次、恢复默认:列表行与巡检卡片共用 */
|
||||
export function useJobActions(job: ScheduledJob) {
|
||||
const update = useUpdateSchedule();
|
||||
const runNow = useRunScheduleNow();
|
||||
const skip = useSkipSchedule();
|
||||
const patch = (body: Parameters<typeof update.mutate>[0]["body"], ok: string) =>
|
||||
update.mutate(
|
||||
{ id: job.id, body },
|
||||
{ onSuccess: () => toast.success(ok), onError: (e) => toast.error(errorMessage(e)) },
|
||||
);
|
||||
const busy = !!job.runningSince || !!job.runRequestedAt;
|
||||
return {
|
||||
update,
|
||||
runNow,
|
||||
skip,
|
||||
patch,
|
||||
busy,
|
||||
setEnabled: (v: boolean) => patch({ enabled: v }, v ? `已启用${job.name}` : `已停用${job.name}`),
|
||||
setSchedule: (schedule: ScheduleSpecInput) => patch({ schedule }, "间隔已更新"),
|
||||
reset: () => patch({ reset: true }, "已恢复默认"),
|
||||
canRun: job.available && !busy && !runNow.isPending,
|
||||
canSkip: job.available && job.enabled && !!job.nextRunAt && !skip.isPending,
|
||||
run: () =>
|
||||
runNow.mutate(
|
||||
{ id: job.id },
|
||||
{
|
||||
onSuccess: (r) => toast.success(r.queued ? "已排队执行" : "已在排队或运行中"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
),
|
||||
skipNext: () =>
|
||||
skip.mutate(
|
||||
{ id: job.id },
|
||||
{ onSuccess: () => toast.success("已跳过本次"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const COLS =
|
||||
"lg:grid-cols-[minmax(0,1.5fr)_2.75rem_8.5rem_minmax(0,1.35fr)_minmax(0,1fr)_auto] lg:items-center lg:gap-x-4";
|
||||
|
||||
function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number; highlight: boolean }) {
|
||||
const a = useJobActions(job);
|
||||
const ref = useRef<HTMLLIElement>(null);
|
||||
useEffect(() => {
|
||||
if (highlight) ref.current?.scrollIntoView({ block: "center" });
|
||||
}, [highlight]);
|
||||
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
|
||||
const failed = job.lastStatus === "failed" && !!job.lastError;
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={ref}
|
||||
id={`job-${job.id}`}
|
||||
aria-label={job.name}
|
||||
className={cn(
|
||||
"grid scroll-mt-20 gap-x-3 gap-y-2 px-4 py-3 grid-cols-[minmax(0,1fr)_auto]",
|
||||
COLS,
|
||||
highlight && "bg-foreground/[0.04] ring-2 ring-ring/40 ring-inset",
|
||||
!job.available && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-body font-medium text-foreground" title={job.name}>
|
||||
{job.name}
|
||||
</p>
|
||||
{job.description ? (
|
||||
<p className="truncate text-meta text-muted-foreground" title={job.description}>
|
||||
{job.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="row-start-1 col-start-2 lg:col-start-auto lg:row-start-auto">
|
||||
<Switch
|
||||
aria-label={`${job.enabled ? "停用" : "启用"}${job.name}`}
|
||||
checked={job.enabled && job.available}
|
||||
disabled={!job.available || a.update.isPending}
|
||||
onCheckedChange={a.setEnabled}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-start-1 row-start-2 flex min-w-0 items-center lg:col-start-auto lg:row-start-auto">
|
||||
<IntervalSelect
|
||||
label={`${job.name}间隔`}
|
||||
schedule={job.schedule}
|
||||
disabled={!job.available}
|
||||
pending={a.update.isPending}
|
||||
onSave={a.setSchedule}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-start-1 row-start-3 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto">
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
{job.lastDurationMs !== null ? <span className="tabular-nums">{formatMs(job.lastDurationMs)}</span> : null}
|
||||
</div>
|
||||
<div className="col-start-2 row-start-3 flex min-w-0 items-center justify-end gap-1 text-meta text-muted-foreground lg:col-start-auto lg:row-start-auto lg:justify-start">
|
||||
<span className="shrink-0 lg:hidden">下次</span>
|
||||
<NextRun job={job} now={now} />
|
||||
</div>
|
||||
<div className="col-start-2 row-start-2 flex items-center justify-end gap-0.5 lg:col-start-auto lg:row-start-auto">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`立即执行${job.name}`}
|
||||
title="立即执行"
|
||||
disabled={!a.canRun}
|
||||
onClick={a.run}
|
||||
>
|
||||
{a.runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`跳过${job.name}本次运行`}
|
||||
title="跳过本次"
|
||||
disabled={!a.canSkip}
|
||||
onClick={a.skipNext}
|
||||
>
|
||||
<SkipForward />
|
||||
</Button>
|
||||
<Button asChild size="icon-sm" variant="ghost" title="运行记录">
|
||||
<Link to={scheduleStreamPath(job.id)} aria-label={`${job.name}运行记录`}>
|
||||
<History />
|
||||
</Link>
|
||||
</Button>
|
||||
{job.customized ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label={`${job.name}恢复默认`}
|
||||
title="恢复默认"
|
||||
disabled={a.update.isPending}
|
||||
onClick={a.reset}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{failed ? (
|
||||
<details className="group col-span-full rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||
<summary className="cursor-pointer truncate group-open:whitespace-normal">失败原因:{job.lastError}</summary>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
||||
</details>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function AutoTaskList({
|
||||
jobs,
|
||||
now,
|
||||
highlightJob,
|
||||
}: {
|
||||
jobs: ScheduledJob[];
|
||||
now: number;
|
||||
highlightJob?: string;
|
||||
}) {
|
||||
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
|
||||
for (const j of jobs) {
|
||||
const list = byGroup.get(j.group);
|
||||
if (list) list.push(j);
|
||||
else byGroup.set(j.group, [j]);
|
||||
}
|
||||
const groups = TASK_GROUPS.filter((g) => byGroup.get(g)?.length);
|
||||
return (
|
||||
<div className="pg-slab overflow-hidden rounded-xl">
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
|
||||
>
|
||||
<span>名称</span>
|
||||
<span>开关</span>
|
||||
<span>间隔</span>
|
||||
<span>上次结果</span>
|
||||
<span>下次运行</span>
|
||||
<span className="text-right">操作</span>
|
||||
</div>
|
||||
{groups.map((g) => {
|
||||
const list = byGroup.get(g) ?? [];
|
||||
return (
|
||||
<section key={g} aria-label={GROUP_LABEL[g]}>
|
||||
<h3 className="flex items-baseline gap-2 border-b border-border/70 bg-foreground/[0.03] px-4 py-1.5 text-meta font-medium text-muted-foreground">
|
||||
{GROUP_LABEL[g]}
|
||||
<span className="tabular-nums">{list.length}</span>
|
||||
</h3>
|
||||
<ul className="divide-y divide-border/70 border-b border-border/70 last:border-b-0">
|
||||
{list.map((j) => (
|
||||
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,13 +12,23 @@ export function IntervalEditor({
|
||||
disabled,
|
||||
pending,
|
||||
onSave,
|
||||
defaultEditing = false,
|
||||
onClose,
|
||||
}: {
|
||||
schedule: ScheduleSpec;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
onSave: (next: ScheduleSpecInput) => void;
|
||||
/** 一出现就处于编辑态(下拉选"自定义"时) */
|
||||
defaultEditing?: boolean;
|
||||
/** 保存或取消后回调(回到下拉) */
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editing, setEditingState] = useState(defaultEditing);
|
||||
const setEditing = (v: boolean) => {
|
||||
setEditingState(v);
|
||||
if (!v) onClose?.();
|
||||
};
|
||||
const init = schedule.type === "interval" ? splitInterval(schedule.everyMs) : { value: 1, unit: "m" as IntervalUnit };
|
||||
const [value, setValue] = useState(String(init.value));
|
||||
const [unit, setUnit] = useState<IntervalUnit>(init.unit);
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { ScheduleSpec, ScheduleSpecInput } from "@umt/contracts";
|
||||
import { useState } from "react";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
|
||||
import { IntervalEditor } from "./interval-editor.tsx";
|
||||
import { describeSchedule, INTERVAL_PRESETS } from "./labels.ts";
|
||||
|
||||
const CUSTOM = "custom";
|
||||
|
||||
/**
|
||||
* 间隔就地改:常用选项下拉,最后一项"自定义…"切到数字 + 单位输入。
|
||||
* cron 调度没有下拉,直接用 IntervalEditor 改表达式。
|
||||
*/
|
||||
export function IntervalSelect({
|
||||
schedule,
|
||||
disabled,
|
||||
pending,
|
||||
onSave,
|
||||
label,
|
||||
}: {
|
||||
schedule: ScheduleSpec;
|
||||
disabled?: boolean;
|
||||
pending?: boolean;
|
||||
onSave: (next: ScheduleSpecInput) => void;
|
||||
/** 无障碍名:"<任务名>间隔" */
|
||||
label: string;
|
||||
}) {
|
||||
const [custom, setCustom] = useState(false);
|
||||
if (schedule.type === "cron" || custom)
|
||||
return (
|
||||
<IntervalEditor
|
||||
schedule={schedule}
|
||||
disabled={disabled}
|
||||
pending={pending}
|
||||
onSave={onSave}
|
||||
defaultEditing={custom}
|
||||
onClose={() => setCustom(false)}
|
||||
/>
|
||||
);
|
||||
const current = String(schedule.everyMs);
|
||||
const options = INTERVAL_PRESETS.includes(schedule.everyMs)
|
||||
? INTERVAL_PRESETS
|
||||
: [...INTERVAL_PRESETS, schedule.everyMs].sort((a, b) => a - b);
|
||||
return (
|
||||
<Select
|
||||
value={current}
|
||||
disabled={disabled || pending}
|
||||
onValueChange={(v) => {
|
||||
if (v === CUSTOM) setCustom(true);
|
||||
else if (v !== current) onSave({ type: "interval", everyMs: Number(v) });
|
||||
}}
|
||||
>
|
||||
<SelectTrigger size="sm" className="h-8 w-32 text-caption" aria-label={label}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{options.map((ms) => (
|
||||
<SelectItem key={ms} value={String(ms)}>
|
||||
{describeSchedule({ type: "interval", everyMs: ms })}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value={CUSTOM}>自定义…</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describeSchedule, formatUsd, INTERVAL_PRESETS, planPath, scheduleStreamPath } from "./labels.ts";
|
||||
|
||||
describe("计划页标签", () => {
|
||||
it("planPath:节锚点与 ?job= 高亮", () => {
|
||||
expect(planPath()).toBe("/console/schedules");
|
||||
expect(planPath({ section: "auto" })).toBe("/console/schedules#auto");
|
||||
expect(planPath({ section: "checks", job: "check.pending" })).toBe("/console/schedules?job=check.pending#checks");
|
||||
});
|
||||
|
||||
it("运行记录跳任务页按定时任务筛选", () => {
|
||||
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
|
||||
});
|
||||
|
||||
it("formatUsd:小额保留 4 位", () => {
|
||||
expect(formatUsd(undefined)).toBe("—");
|
||||
expect(formatUsd(0)).toBe("$0");
|
||||
expect(formatUsd(0.0031258)).toBe("$0.0031");
|
||||
expect(formatUsd(1.234)).toBe("$1.23");
|
||||
});
|
||||
|
||||
it("间隔选项按天、小时、分钟、秒说", () => {
|
||||
expect(INTERVAL_PRESETS.map((ms) => describeSchedule({ type: "interval", everyMs: ms }))).toEqual([
|
||||
"每 30 秒",
|
||||
"每 1 分钟",
|
||||
"每 5 分钟",
|
||||
"每 10 分钟",
|
||||
"每 30 分钟",
|
||||
"每 1 小时",
|
||||
"每 6 小时",
|
||||
"每 12 小时",
|
||||
"每 1 天",
|
||||
"每 7 天",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,19 @@ import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
// 定时任务看板的标签映射(只在本页用,不放 lib/labels.ts)
|
||||
|
||||
export const GROUP_ORDER: ScheduleGroup[] = ["collect", "download", "organize", "maintain", "agent"];
|
||||
/** 计划页「自动任务」一节的分组(Agent 巡检单独一节) */
|
||||
export const TASK_GROUPS: ScheduleGroup[] = ["collect", "download", "organize", "maintain"];
|
||||
|
||||
/** 计划页两节的锚点:/console/schedules#auto、#checks */
|
||||
export type PlanSection = "auto" | "checks";
|
||||
export const PLAN_PATH = "/console/schedules";
|
||||
export function planPath(opts: { section?: PlanSection; job?: string } = {}): string {
|
||||
const q = opts.job ? `?job=${encodeURIComponent(opts.job)}` : "";
|
||||
return `${PLAN_PATH}${q}${opts.section ? `#${opts.section}` : ""}`;
|
||||
}
|
||||
|
||||
/** 任务页里某个定时任务触发的任务(businessKind=schedule) */
|
||||
export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`;
|
||||
|
||||
export const GROUP_LABEL: Record<ScheduleGroup, string> = {
|
||||
collect: "采集",
|
||||
@@ -43,6 +56,27 @@ export type IntervalUnit = "s" | "m" | "h";
|
||||
export const UNIT_MS: Record<IntervalUnit, number> = { s: 1000, m: 60_000, h: 3600_000 };
|
||||
export const UNIT_LABEL: Record<IntervalUnit, string> = { s: "秒", m: "分钟", h: "小时" };
|
||||
|
||||
/** 间隔下拉的常用选项(毫秒);当前值不在其中时下拉另列一项 */
|
||||
export const INTERVAL_PRESETS: number[] = [
|
||||
30_000,
|
||||
60_000,
|
||||
5 * 60_000,
|
||||
10 * 60_000,
|
||||
30 * 60_000,
|
||||
3600_000,
|
||||
6 * 3600_000,
|
||||
12 * 3600_000,
|
||||
24 * 3600_000,
|
||||
7 * 24 * 3600_000,
|
||||
];
|
||||
|
||||
/** 美元费用:"$0.0031" / "$0.12";空值 "—" */
|
||||
export function formatUsd(v: number | null | undefined): string {
|
||||
if (v === null || v === undefined) return "—";
|
||||
if (v === 0) return "$0";
|
||||
return `$${v < 0.1 ? v.toFixed(4) : v.toFixed(2)}`;
|
||||
}
|
||||
|
||||
/** 毫秒 → 最大整除单位 */
|
||||
export function splitInterval(ms: number): { value: number; unit: IntervalUnit } {
|
||||
if (ms % UNIT_MS.h === 0) return { value: ms / UNIT_MS.h, unit: "h" };
|
||||
|
||||
@@ -1,19 +1,24 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob, type ScheduleGroup } from "@umt/contracts";
|
||||
import { AlertTriangle, CalendarClock, CircleCheck, LoaderCircle, Plus, XCircle } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
|
||||
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { groupRunsBySchedule, useScheduleAgentRuns } from "~/api/agent.ts";
|
||||
import { usePiUpstreams } from "~/api/pi.ts";
|
||||
import { useScheduleBoard } from "~/api/schedules.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { AgentCheckCard, type DefaultUpstream } from "./agent-check-card.tsx";
|
||||
import { AgentCheckDialog } from "./agent-check-dialog.tsx";
|
||||
import { GROUP_LABEL, GROUP_ORDER } from "./labels.ts";
|
||||
import { ScheduleCard } from "./schedule-card.tsx";
|
||||
import { AutoTaskList } from "./auto-task-list.tsx";
|
||||
import { formatUsd } from "./labels.ts";
|
||||
import { RunsSheet } from "./runs-sheet.tsx";
|
||||
|
||||
// 定时任务看板(W7-C),IA-3 起是任务页的"计划"页签。卡片"运行记录"跳到任务流按该定时任务筛选。
|
||||
// 计划页(FE-6,/console/schedules):「自动任务」= 程序按固定周期执行的定时任务(紧凑列表);
|
||||
// 「Agent 巡检」= 到点发起 Agent 会话的定时任务(卡片)。自动任务的运行记录跳任务页按该定时任务筛选。
|
||||
|
||||
/** 每秒一跳的当前时间(倒计时用) */
|
||||
export function useNow(ms = 1000) {
|
||||
@@ -45,46 +50,125 @@ export function useLiveSchedules() {
|
||||
return useScheduleBoard();
|
||||
}
|
||||
|
||||
/** 任务流里某个定时任务的筛选地址 */
|
||||
export const scheduleStreamPath = (id: string) => `/console/tasks?tab=all&kind=schedule&ref=${encodeURIComponent(id)}`;
|
||||
const WEEK_MS = 7 * 24 * 3600_000;
|
||||
|
||||
export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
const _qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
/** 两节的计数:自动任务 启用/失败/运行中;巡检 启用/失败/近 7 天费用 */
|
||||
export function planStats(items: ScheduledJob[], checkCosts: { at: string; cost: number | undefined }[], now: number) {
|
||||
const auto = items.filter((j) => j.kind === "task");
|
||||
const checks = items.filter((j) => j.kind === "agent_check");
|
||||
const count = (list: ScheduledJob[]) => ({
|
||||
total: list.length,
|
||||
enabled: list.filter((j) => j.enabled && j.available).length,
|
||||
failed: list.filter((j) => j.lastStatus === "failed").length,
|
||||
running: list.filter((j) => !!j.runningSince).length,
|
||||
});
|
||||
const recent = checkCosts.filter((c) => now - new Date(c.at).getTime() <= WEEK_MS);
|
||||
return {
|
||||
auto: count(auto),
|
||||
checks: {
|
||||
...count(checks),
|
||||
weekRuns: recent.length,
|
||||
weekCost: recent.reduce((s, c) => s + (c.cost ?? 0), 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function SectionHead({ id, title, hint, actions }: { id: string; title: string; hint: string; actions?: ReactNode }) {
|
||||
return (
|
||||
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
|
||||
<div className="min-w-0">
|
||||
<h2 id={id} className="scroll-mt-20 text-title-sm font-semibold text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="mt-0.5 text-caption text-muted-foreground">{hint}</p>
|
||||
</div>
|
||||
{actions ? <div className="ml-auto flex items-center gap-2">{actions}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
const board = useLiveSchedules();
|
||||
const agentRuns = useScheduleAgentRuns();
|
||||
const upstreams = usePiUpstreams();
|
||||
const now = useNow();
|
||||
const { hash } = useLocation();
|
||||
const [editing, setEditing] = useState<{ job: ScheduledJob | null; key: number } | null>(null);
|
||||
const [history, setHistory] = useState<ScheduledJob | null>(null);
|
||||
|
||||
const data = board.data;
|
||||
const items = data?.items ?? [];
|
||||
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
|
||||
for (const g of GROUP_ORDER) byGroup.set(g, []);
|
||||
for (const j of items) byGroup.get(j.group)?.push(j);
|
||||
const stats = data?.stats;
|
||||
const val = (n: number | undefined) => (board.isPending ? "…" : board.isError ? "—" : (n ?? 0));
|
||||
const autoJobs = items.filter((j) => j.kind === "task");
|
||||
const checks = items.filter((j) => j.kind === "agent_check");
|
||||
const runsByJob = groupRunsBySchedule(agentRuns.data ?? []);
|
||||
const def = upstreams.data?.items.find((u) => u.isDefault);
|
||||
const upstream: DefaultUpstream = def ? { name: def.name, model: def.defaultModel ?? null } : null;
|
||||
const s = planStats(
|
||||
items,
|
||||
(agentRuns.data ?? []).map((r) => ({ at: r.createdAt, cost: r.usage.costUsd })),
|
||||
now,
|
||||
);
|
||||
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
|
||||
|
||||
// 首页计数链到 #auto / #checks:数据到了再滚过去
|
||||
const loaded = !!data;
|
||||
useEffect(() => {
|
||||
if (!loaded || highlightJob) return;
|
||||
const id = hash.replace(/^#/, "");
|
||||
if (id === "auto" || id === "checks") document.getElementById(id)?.scrollIntoView({ block: "start" });
|
||||
}, [loaded, hash, highlightJob]);
|
||||
|
||||
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ block: "start", behavior: "smooth" });
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex flex-wrap items-center gap-2">
|
||||
<p className="text-caption text-muted-foreground">周期任务与 Agent 巡检的开关、间隔和下次运行。</p>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||
<Plus />
|
||||
新建巡检
|
||||
</Button>
|
||||
<div className="mb-4 grid gap-3 lg:grid-cols-2">
|
||||
<div className="grid gap-1.5">
|
||||
<p className="px-1 text-meta font-medium text-muted-foreground">自动任务</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile
|
||||
label="已启用"
|
||||
icon={CircleCheck}
|
||||
value={val(s.auto.enabled)}
|
||||
hint={board.isPending ? undefined : `共 ${s.auto.total} 项`}
|
||||
onClick={() => jump("auto")}
|
||||
/>
|
||||
<StatTile
|
||||
label="上次失败"
|
||||
icon={XCircle}
|
||||
tone={s.auto.failed > 0 ? "destructive" : "neutral"}
|
||||
value={val(s.auto.failed)}
|
||||
onClick={() => jump("auto")}
|
||||
/>
|
||||
<StatTile label="运行中" icon={LoaderCircle} value={val(s.auto.running)} onClick={() => jump("auto")} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<p className="px-1 text-meta font-medium text-muted-foreground">Agent 巡检</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<StatTile
|
||||
label="已启用"
|
||||
icon={Bot}
|
||||
value={val(s.checks.enabled)}
|
||||
hint={board.isPending ? undefined : `共 ${s.checks.total} 项`}
|
||||
onClick={() => jump("checks")}
|
||||
/>
|
||||
<StatTile
|
||||
label="上次失败"
|
||||
icon={XCircle}
|
||||
tone={s.checks.failed > 0 ? "destructive" : "neutral"}
|
||||
value={val(s.checks.failed)}
|
||||
onClick={() => jump("checks")}
|
||||
/>
|
||||
<StatTile
|
||||
label="近 7 天费用"
|
||||
icon={Coins}
|
||||
value={agentRuns.isPending ? "…" : agentRuns.isError ? "—" : formatUsd(s.checks.weekCost)}
|
||||
hint={agentRuns.data ? `${s.checks.weekRuns} 次会话` : undefined}
|
||||
onClick={() => jump("checks")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-2 lg:grid-cols-4">
|
||||
<StatTile label="已启用" icon={CircleCheck} value={val(stats?.enabled)} />
|
||||
<StatTile
|
||||
label="上次失败"
|
||||
icon={XCircle}
|
||||
tone={(stats?.failed ?? 0) > 0 ? "destructive" : "neutral"}
|
||||
value={val(stats?.failed)}
|
||||
/>
|
||||
<StatTile label="运行中" icon={LoaderCircle} value={val(stats?.running)} />
|
||||
<StatTile label="全部" icon={CalendarClock} value={val(stats?.total)} />
|
||||
</div>
|
||||
|
||||
{data && !data.schedulerOnline ? (
|
||||
@@ -93,53 +177,67 @@ export function ScheduleBoard({ highlightJob }: { highlightJob?: string }) {
|
||||
className="mb-4 flex items-center gap-2 rounded-tight border border-warning/30 bg-warning/10 px-3 py-2 text-caption text-warning"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4 shrink-0" />
|
||||
调度器未在线,任务不会按时运行。
|
||||
调度器未在线,定时任务不会按时运行。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mb-4" />
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{board.isPending ? (
|
||||
<LoadingState rows={6} />
|
||||
<LoadingState rows={8} />
|
||||
) : board.isError ? (
|
||||
<ErrorState error={board.error} onRetry={() => board.refetch()} />
|
||||
) : items.length === 0 ? (
|
||||
<EmptyState icon={CalendarClock} title="还没有定时任务" description="worker 启动后自动登记。" />
|
||||
) : (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-5">
|
||||
{GROUP_ORDER.map((g) => {
|
||||
const jobs = byGroup.get(g) ?? [];
|
||||
return (
|
||||
<section key={g} aria-label={GROUP_LABEL[g]} className="grid min-w-0 gap-2.5">
|
||||
<h2 className="flex items-baseline justify-between px-1 text-caption font-medium text-muted-foreground">
|
||||
<span>{GROUP_LABEL[g]}</span>
|
||||
<span className="tabular-nums">{jobs.length}</span>
|
||||
</h2>
|
||||
{jobs.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed border-border/70 px-4 py-6 text-center text-meta text-muted-foreground">
|
||||
{g === "agent" ? "还没有巡检。" : "还没有定时任务。"}
|
||||
</p>
|
||||
) : (
|
||||
jobs.map((j) => (
|
||||
<ScheduleCard
|
||||
key={j.id}
|
||||
job={j}
|
||||
now={now}
|
||||
highlight={highlightJob === j.id}
|
||||
onHistory={(job) => navigate(scheduleStreamPath(job.id))}
|
||||
onEdit={(job) => setEditing({ job, key: Date.now() })}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
<div className="grid gap-8">
|
||||
<section aria-labelledby="auto">
|
||||
<SectionHead
|
||||
id="auto"
|
||||
title="自动任务"
|
||||
hint="程序按固定周期执行,不调用模型。间隔可直接改,改过的可恢复默认。"
|
||||
/>
|
||||
{autoJobs.length === 0 ? (
|
||||
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
|
||||
) : (
|
||||
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section aria-labelledby="checks">
|
||||
<SectionHead
|
||||
id="checks"
|
||||
title="Agent 巡检"
|
||||
hint="到点按目标发起一次 Agent 会话,只在能力白名单和预算内执行;触发条件不满足时跳过,不调用模型。"
|
||||
actions={
|
||||
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
|
||||
<Plus />
|
||||
新建巡检
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
{checks.length === 0 ? (
|
||||
<EmptyState icon={Bot} title="还没有巡检" description="新建一个巡检,到点由 Agent 按目标检查并汇报。" />
|
||||
) : (
|
||||
<div className="grid items-start gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{checks.map((j) => (
|
||||
<AgentCheckCard
|
||||
key={j.id}
|
||||
job={j}
|
||||
now={now}
|
||||
runs={runsByJob.get(j.id) ?? []}
|
||||
upstream={upstream}
|
||||
highlight={highlightJob === j.id}
|
||||
onEdit={(job) => setEditing({ job, key: Date.now() })}
|
||||
onHistory={setHistory}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{editing ? (
|
||||
<AgentCheckDialog key={editing.key} open job={editing.job} onOpenChange={(o) => !o && setEditing(null)} />
|
||||
) : null}
|
||||
<RunsSheet job={history} onClose={() => setHistory(null)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import type { AgentCheckConfig, ScheduledJob, ScheduleSpecInput } from "@umt/contracts";
|
||||
import { History, LoaderCircle, Pencil, Play, RotateCcw, SkipForward, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useDeleteSchedule, useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { IntervalEditor } from "./interval-editor.tsx";
|
||||
import { CHECK_TRIGGER_LABEL, countdown, formatMs, RUN_STATUS } from "./labels.ts";
|
||||
|
||||
/** 下次运行一栏:运行中 > 排队中 > 已停用 > 倒计时 */
|
||||
function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
|
||||
if (job.runningSince)
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 text-foreground">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" />
|
||||
运行中 · 已 {formatMs(Math.max(0, now - new Date(job.runningSince).getTime()))}
|
||||
</span>
|
||||
);
|
||||
if (job.runRequestedAt) return <span className="text-foreground">排队中</span>;
|
||||
if (!job.available) return <span>未配置</span>;
|
||||
if (!job.enabled) return <span>已停用</span>;
|
||||
if (!job.nextRunAt) return <span>待调度</span>;
|
||||
return <span className="tabular-nums text-foreground">{countdown(job.nextRunAt, now)}</span>;
|
||||
}
|
||||
|
||||
function AgentCheckSummary({ config }: { config: Partial<AgentCheckConfig> }) {
|
||||
const budget = config.budget ?? {};
|
||||
const allow = config.capabilityAllowlist ?? [];
|
||||
return (
|
||||
<div className="grid gap-1.5 rounded-tight border border-border/70 px-2.5 py-2 text-meta text-muted-foreground">
|
||||
<p className="line-clamp-2 text-caption text-foreground" title={config.goalTemplate}>
|
||||
{config.goalTemplate ?? "—"}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5">
|
||||
<span>条件:{CHECK_TRIGGER_LABEL[config.triggerCondition ?? "always"]}</span>
|
||||
<span>
|
||||
预算:{budget.maxToolCalls ? `${budget.maxToolCalls} 次调用` : "默认"}
|
||||
{budget.maxDurationSec ? ` · ${budget.maxDurationSec} 秒` : ""}
|
||||
</span>
|
||||
<span>能力:{allow.length ? `${allow.length} 项` : "全部只读能力"}</span>
|
||||
{config.allowRecover ? <span>允许授权内恢复</span> : null}
|
||||
{config.reportAsDecision ? <span>写入待处理</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ScheduleCard({
|
||||
job,
|
||||
now,
|
||||
onHistory,
|
||||
onEdit,
|
||||
highlight = false,
|
||||
}: {
|
||||
job: ScheduledJob;
|
||||
now: number;
|
||||
onHistory: (job: ScheduledJob) => void;
|
||||
onEdit: (job: ScheduledJob) => void;
|
||||
/** 从任务流跳来时高亮并滚到可见 */
|
||||
highlight?: boolean;
|
||||
}) {
|
||||
const update = useUpdateSchedule();
|
||||
const runNow = useRunScheduleNow();
|
||||
const skip = useSkipSchedule();
|
||||
const del = useDeleteSchedule();
|
||||
const ref = useRef<HTMLElement>(null);
|
||||
useEffect(() => {
|
||||
if (highlight) ref.current?.scrollIntoView({ block: "center" });
|
||||
}, [highlight]);
|
||||
const busy = !!job.runningSince || !!job.runRequestedAt;
|
||||
const last = job.lastStatus ? RUN_STATUS[job.lastStatus] : null;
|
||||
|
||||
const patch = (body: Parameters<typeof update.mutate>[0]["body"], ok: string) =>
|
||||
update.mutate(
|
||||
{ id: job.id, body },
|
||||
{ onSuccess: () => toast.success(ok), onError: (e) => toast.error(errorMessage(e)) },
|
||||
);
|
||||
|
||||
return (
|
||||
<article
|
||||
id={`job-${job.id}`}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"pg-slab grid min-w-0 scroll-mt-20 gap-3 rounded-xl px-4 py-3.5",
|
||||
highlight && "ring-2 ring-ring/50",
|
||||
)}
|
||||
aria-label={job.name}
|
||||
>
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-lead font-medium text-foreground" title={job.name}>
|
||||
{job.name}
|
||||
</h3>
|
||||
{job.description ? (
|
||||
<p className="mt-0.5 line-clamp-2 text-meta text-muted-foreground">{job.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Switch
|
||||
aria-label={job.enabled ? "停用" : "启用"}
|
||||
checked={job.enabled && job.available}
|
||||
disabled={!job.available || update.isPending}
|
||||
onCheckedChange={(v) => patch({ enabled: v }, v ? "已启用" : "已停用")}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{job.kind === "agent_check" ? <AgentCheckSummary config={job.config as Partial<AgentCheckConfig>} /> : null}
|
||||
|
||||
<dl className="grid grid-cols-[auto_1fr] items-center gap-x-3 gap-y-1.5 text-meta text-muted-foreground">
|
||||
<dt>间隔</dt>
|
||||
<dd className="min-w-0">
|
||||
<IntervalEditor
|
||||
schedule={job.schedule}
|
||||
disabled={!job.available}
|
||||
pending={update.isPending}
|
||||
onSave={(schedule: ScheduleSpecInput) => patch({ schedule }, "间隔已更新")}
|
||||
/>
|
||||
</dd>
|
||||
<dt>上次</dt>
|
||||
<dd className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : <span>未运行</span>}
|
||||
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
|
||||
{job.lastDurationMs !== null ? <span>耗时 {formatMs(job.lastDurationMs)}</span> : null}
|
||||
</dd>
|
||||
<dt>下次</dt>
|
||||
<dd className="min-w-0">
|
||||
<NextRun job={job} now={now} />
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
{job.lastStatus === "failed" && job.lastError ? (
|
||||
<details className="group rounded-tight border border-destructive/30 bg-destructive/10 px-2.5 py-1.5 text-meta text-destructive">
|
||||
<summary className="cursor-pointer truncate group-open:whitespace-normal">{job.lastError}</summary>
|
||||
<p className="mt-1 break-words whitespace-pre-wrap">{job.lastError}</p>
|
||||
</details>
|
||||
) : null}
|
||||
|
||||
<footer className="flex flex-wrap items-center gap-1.5">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!job.available || busy || runNow.isPending}
|
||||
onClick={() =>
|
||||
runNow.mutate(
|
||||
{ id: job.id },
|
||||
{
|
||||
onSuccess: (r) => toast.success(r.queued ? "已排队执行" : "已在排队或运行中"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{runNow.isPending ? <LoaderCircle className="animate-spin" /> : <Play />}
|
||||
立即执行
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={!job.available || !job.enabled || !job.nextRunAt || skip.isPending}
|
||||
onClick={() =>
|
||||
skip.mutate(
|
||||
{ id: job.id },
|
||||
{ onSuccess: () => toast.success("已跳过本次"), onError: (e) => toast.error(errorMessage(e)) },
|
||||
)
|
||||
}
|
||||
>
|
||||
<SkipForward />
|
||||
跳过本次
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => onHistory(job)}>
|
||||
<History />
|
||||
运行记录
|
||||
</Button>
|
||||
<span className="ml-auto flex items-center gap-1">
|
||||
{job.customized ? (
|
||||
<Button
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
aria-label="恢复默认"
|
||||
title="恢复默认"
|
||||
disabled={update.isPending}
|
||||
onClick={() => patch({ reset: true }, "已恢复默认")}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
) : null}
|
||||
{job.kind === "agent_check" ? (
|
||||
<Button size="icon-sm" variant="ghost" aria-label="编辑巡检" title="编辑巡检" onClick={() => onEdit(job)}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
) : null}
|
||||
{!job.builtin ? (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="icon-sm" variant="ghost" aria-label="删除巡检" title="删除巡检">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
}
|
||||
title={`删除「${job.name}」?`}
|
||||
description="运行记录一并删除,已产生的 Agent 会话保留。"
|
||||
confirmLabel="删除巡检"
|
||||
destructive
|
||||
onConfirm={() =>
|
||||
del.mutateAsync({ id: job.id }).then(
|
||||
() => toast.success("已删除"),
|
||||
(e) => {
|
||||
toast.error(errorMessage(e));
|
||||
throw e;
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function businessLink(b: EngineRun["business"], input?: unknown): { to: s
|
||||
case "agent":
|
||||
return { to: `/console/agent/${id}`, label: "Agent 会话" };
|
||||
case "schedule":
|
||||
return { to: `/console/tasks?tab=plan&job=${id}`, label: "定时任务" };
|
||||
return { to: `/console/schedules?job=${id}`, label: "定时任务" };
|
||||
case "feed":
|
||||
return { to: consolePath("sources"), label: "Feed" };
|
||||
case "site":
|
||||
|
||||
@@ -94,7 +94,7 @@ function ScheduleBanner({ jobId, onClear }: { jobId: string; onClear: () => void
|
||||
运行记录
|
||||
</Button>
|
||||
<Button asChild variant="ghost" size="xs">
|
||||
<Link to={`/console/tasks?tab=plan&job=${encodeURIComponent(jobId)}`}>
|
||||
<Link to={`/console/schedules?job=${encodeURIComponent(jobId)}`}>
|
||||
<ExternalLink />
|
||||
计划
|
||||
</Link>
|
||||
|
||||
@@ -4,7 +4,9 @@ import {
|
||||
Activity,
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
CalendarClock,
|
||||
CalendarCog,
|
||||
CircleCheck,
|
||||
Gauge,
|
||||
HardDrive,
|
||||
@@ -20,10 +22,10 @@ import type { ReactNode } from "react";
|
||||
import { useRef } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useScheduleAgentRuns } from "~/api/agent.ts";
|
||||
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
|
||||
import { useDecisions } from "~/api/decisions.ts";
|
||||
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
|
||||
import { useUpdateSchedule } from "~/api/schedules.ts";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { useSystemHealth } from "~/api/system.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
@@ -33,8 +35,8 @@ import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
|
||||
import { countdown, RUN_STATUS } from "~/components/schedules/labels.ts";
|
||||
import { useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx";
|
||||
import { formatUsd, planPath } from "~/components/schedules/labels.ts";
|
||||
import { planStats, useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx";
|
||||
import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
|
||||
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
@@ -313,83 +315,83 @@ function MediaOverview({ a }: { a: Dashboard["automation"] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 计划带:下一次运行的 5 项 + 计数 + 快速开关 ───
|
||||
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
|
||||
function PlanCount({
|
||||
to,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
to={to}
|
||||
className="flex min-w-0 flex-col gap-1 rounded-xl border border-border/70 px-3 py-2.5 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<Icon className="h-3.5 w-3.5" />
|
||||
{label}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-baseline gap-x-3 gap-y-0.5 text-caption text-muted-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
function Num({ children, tone }: { children: ReactNode; tone?: "destructive" }) {
|
||||
return (
|
||||
<span className={cn("text-lead font-semibold tabular-nums", tone ? "text-destructive" : "text-foreground")}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanStrip() {
|
||||
const board = useLiveSchedules();
|
||||
const now = useNow(1000);
|
||||
const update = useUpdateSchedule();
|
||||
const items = board.data?.items ?? [];
|
||||
const next = items
|
||||
.filter((j) => j.available && j.enabled && j.nextRunAt)
|
||||
.sort((x, y) => (x.nextRunAt ?? "").localeCompare(y.nextRunAt ?? ""))
|
||||
.slice(0, 5);
|
||||
const s = board.data?.stats;
|
||||
const runs = useScheduleAgentRuns();
|
||||
const now = useNow(60_000);
|
||||
const s = planStats(
|
||||
board.data?.items ?? [],
|
||||
(runs.data ?? []).map((r) => ({ at: r.createdAt, cost: r.usage.costUsd })),
|
||||
now,
|
||||
);
|
||||
return (
|
||||
<Section
|
||||
title="计划"
|
||||
icon={CalendarClock}
|
||||
to="/console/tasks?tab=plan"
|
||||
className="md:col-span-2 xl:col-span-3"
|
||||
icon={CalendarCog}
|
||||
to={planPath()}
|
||||
extra={
|
||||
s ? (
|
||||
<span className="text-meta text-muted-foreground">
|
||||
启用 <span className="font-medium text-foreground tabular-nums">{s.enabled}</span> / {s.total}
|
||||
{s.failed ? (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="font-medium text-destructive tabular-nums">上次失败 {s.failed}</span>
|
||||
</>
|
||||
) : null}
|
||||
{board.data && !board.data.schedulerOnline ? <span className="text-warning"> · 调度器未在线</span> : null}
|
||||
</span>
|
||||
) : null
|
||||
board.data && !board.data.schedulerOnline ? <span className="text-meta text-warning">调度器未在线</span> : null
|
||||
}
|
||||
>
|
||||
{board.isPending ? (
|
||||
<LoadingState rows={2} />
|
||||
) : board.isError ? (
|
||||
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
|
||||
) : next.length === 0 ? (
|
||||
<EmptyState plain icon={CalendarClock} title="没有待运行的定时任务" className="py-4" />
|
||||
) : (
|
||||
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
||||
{next.map((j) => {
|
||||
const last = j.lastStatus ? RUN_STATUS[j.lastStatus] : null;
|
||||
return (
|
||||
<li key={j.id} className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-border/70 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/console/tasks?tab=plan&job=${encodeURIComponent(j.id)}`}
|
||||
className="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
|
||||
title={j.name}
|
||||
>
|
||||
{j.name}
|
||||
</Link>
|
||||
<Switch
|
||||
checked={j.enabled}
|
||||
disabled={update.isPending}
|
||||
aria-label={`${j.enabled ? "停用" : "启用"}${j.name}`}
|
||||
onCheckedChange={(v) =>
|
||||
update.mutate(
|
||||
{ id: j.id, body: { enabled: v } },
|
||||
{
|
||||
onSuccess: () => toast.success(v ? `已启用${j.name}` : `已停用${j.name}`),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<p className="flex items-center justify-between gap-2 text-meta text-muted-foreground">
|
||||
<span className="tabular-nums">
|
||||
{j.runningSince ? "运行中" : j.nextRunAt ? countdown(j.nextRunAt, now) : "—"}
|
||||
</span>
|
||||
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : null}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<PlanCount to={planPath({ section: "auto" })} label="自动任务" icon={CalendarClock}>
|
||||
<span>
|
||||
启用 <Num>{s.auto.enabled}</Num> / {s.auto.total}
|
||||
</span>
|
||||
<span>
|
||||
失败 <Num tone={s.auto.failed ? "destructive" : undefined}>{s.auto.failed}</Num>
|
||||
</span>
|
||||
</PlanCount>
|
||||
<PlanCount to={planPath({ section: "checks" })} label="Agent 巡检" icon={Bot}>
|
||||
<span>
|
||||
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
|
||||
</span>
|
||||
<span title="近 7 天巡检会话费用合计">
|
||||
近 7 天 <Num>{runs.isPending ? "…" : runs.isError ? "—" : formatUsd(s.checks.weekCost)}</Num>
|
||||
</span>
|
||||
</PlanCount>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -23,13 +23,13 @@ export function legacyRedirect(pathname: string, search: string): string | null
|
||||
if (path === "/console/engine") return withRest(tab ? `/console/tasks?tab=${tab}` : "/console/tasks");
|
||||
const engineRun = /^\/console\/engine\/([^/]+)$/.exec(path);
|
||||
if (engineRun) return withRest(`/console/tasks?run=${engineRun[1]}`);
|
||||
// IA-3:定时任务看板成为任务页"计划"页签
|
||||
if (path === "/console/schedules") return withRest("/console/tasks?tab=plan");
|
||||
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
|
||||
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
|
||||
|
||||
if (!tab) return null;
|
||||
if (path === "/console/tasks") {
|
||||
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
|
||||
if (tab === "plan") return withRest("/console/schedules");
|
||||
if (tab === "downloads") return withRest("/console/downloads");
|
||||
if (tab === "plans") return withRest("/console/downloads/plans");
|
||||
// 旧"业务任务"页签:统一任务流
|
||||
@@ -58,7 +58,8 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
|
||||
{ from: "/console/engine", to: "/console/tasks" },
|
||||
{ from: "/console/engine?status=failed", to: "/console/tasks?status=failed" },
|
||||
{ from: "/console/engine/run-1", to: "/console/tasks?run=run-1" },
|
||||
{ from: "/console/schedules", to: "/console/tasks?tab=plan" },
|
||||
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||
{ from: "/console/identity?tab=aliases", to: "/console/library?view=aliases" },
|
||||
|
||||
@@ -1,8 +1,39 @@
|
||||
import { legacyRedirectLoader } from "./redirects.ts";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCw } from "lucide-react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { agentKeys } from "~/api/agent.ts";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { PlanBoard } from "~/components/schedules/schedule-board.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
|
||||
// IA-3:定时任务看板成为任务页"计划"页签(/console/tasks?tab=plan)
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
// FE-6:计划页(定时任务配置)与任务页(运行记录)分开。?job= 高亮并滚到该定时任务;#auto / #checks 定位到节。
|
||||
|
||||
export default function LegacyRedirect() {
|
||||
return null;
|
||||
export const meta = () => [{ title: "计划 · UHD 媒体自动化控制台" }];
|
||||
|
||||
export default function SchedulesPage() {
|
||||
const qc = useQueryClient();
|
||||
const [params] = useSearchParams();
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="计划"
|
||||
description="定时任务的开关、间隔和下次运行。每次运行的结果在任务页。"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
||||
qc.invalidateQueries({ queryKey: [...agentKeys.all, "list"] });
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<PlanBoard highlightJob={params.get("job") ?? undefined} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -543,7 +543,7 @@ export default function StatusPage() {
|
||||
icon={Timer}
|
||||
level={d.schedules.level}
|
||||
action={
|
||||
<Link to="/console/tasks?tab=plan" className="text-meta text-muted-foreground hover:text-foreground">
|
||||
<Link to="/console/schedules" className="text-meta text-muted-foreground hover:text-foreground">
|
||||
查看计划
|
||||
</Link>
|
||||
}
|
||||
@@ -560,7 +560,7 @@ export default function StatusPage() {
|
||||
{d.schedules.failing.map((f) => (
|
||||
<li key={f.id} className="min-w-0 text-caption">
|
||||
<Link
|
||||
to={`/console/tasks?tab=plan&job=${encodeURIComponent(f.id)}`}
|
||||
to={`/console/schedules?job=${encodeURIComponent(f.id)}`}
|
||||
className="font-medium text-foreground hover:underline"
|
||||
>
|
||||
{f.name}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useEngineRun, useEngineStats } from "~/api/engine.ts";
|
||||
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { ScheduleBoard } from "~/components/schedules/schedule-board.tsx";
|
||||
import { runTitle } from "~/components/tasks/labels.ts";
|
||||
import { RunActions, TaskDetailBody, TaskStatusLine } from "~/components/tasks/task-detail.tsx";
|
||||
import { type StreamFilters, type StreamTab, TaskStream } from "~/components/tasks/task-stream.tsx";
|
||||
@@ -16,11 +15,11 @@ import { legacyRedirectLoader } from "./redirects.ts";
|
||||
|
||||
export const meta = () => [{ title: "任务 · UHD 媒体自动化控制台" }];
|
||||
|
||||
// 旧的 ?tab=downloads / plans / business 跳到下载面板 / 下载计划 / 任务流
|
||||
// 旧的 ?tab=downloads / plans / business / plan 跳到下载面板 / 下载计划 / 任务流 / 计划页
|
||||
export const clientLoader = legacyRedirectLoader;
|
||||
|
||||
type Tab = StreamTab | "plan";
|
||||
const TABS: Tab[] = ["all", "active", "failed", "plan"];
|
||||
type Tab = StreamTab;
|
||||
const TABS: Tab[] = ["all", "active", "failed"];
|
||||
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"];
|
||||
|
||||
function Count({ n }: { n: number | undefined }) {
|
||||
@@ -102,14 +101,7 @@ export default function TasksPage() {
|
||||
title="任务"
|
||||
description="每次运行都是一条任务,不论定时、事件、手动还是 Agent 触发。"
|
||||
actions={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
qc.invalidateQueries({ queryKey: qk.all("engine") });
|
||||
qc.invalidateQueries({ queryKey: qk.all("schedules") });
|
||||
}}
|
||||
>
|
||||
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
|
||||
<RotateCw />
|
||||
刷新
|
||||
</Button>
|
||||
@@ -120,7 +112,6 @@ export default function TasksPage() {
|
||||
onValueChange={(v) =>
|
||||
update((p) => {
|
||||
p.set("tab", v);
|
||||
p.delete("job");
|
||||
if (v !== "all") p.delete("status");
|
||||
})
|
||||
}
|
||||
@@ -135,26 +126,21 @@ export default function TasksPage() {
|
||||
失败
|
||||
<Count n={by?.failed} />
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="plan">计划</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<div className="mt-4">
|
||||
{tab === "plan" ? (
|
||||
<ScheduleBoard highlightJob={params.get("job") ?? undefined} />
|
||||
) : (
|
||||
<TaskStream
|
||||
tab={tab}
|
||||
filters={filters}
|
||||
setFilter={setFilter}
|
||||
resetFilters={() =>
|
||||
update((p) => {
|
||||
for (const k of FILTER_KEYS) p.delete(k);
|
||||
})
|
||||
}
|
||||
onOpen={(id) => update((p) => p.set("run", id))}
|
||||
selectedId={runId}
|
||||
/>
|
||||
)}
|
||||
<TaskStream
|
||||
tab={tab}
|
||||
filters={filters}
|
||||
setFilter={setFilter}
|
||||
resetFilters={() =>
|
||||
update((p) => {
|
||||
for (const k of FILTER_KEYS) p.delete(k);
|
||||
})
|
||||
}
|
||||
onOpen={(id) => update((p) => p.set("run", id))}
|
||||
selectedId={runId}
|
||||
/>
|
||||
</div>
|
||||
<RunSheet
|
||||
runId={runId}
|
||||
|
||||
@@ -51,7 +51,9 @@ const STATIC_ROUTES = [
|
||||
"/console/tasks",
|
||||
"/console/tasks?tab=active",
|
||||
"/console/tasks?tab=failed",
|
||||
"/console/tasks?tab=plan",
|
||||
"/console/schedules",
|
||||
"/console/schedules#checks",
|
||||
"/console/schedules?job=trash.purge",
|
||||
"/console/agent",
|
||||
"/console/agent?mock=1",
|
||||
"/console/audit",
|
||||
@@ -107,7 +109,9 @@ const REDIRECT_ROUTES = [
|
||||
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
|
||||
{ from: "/console/engine", to: "/console/tasks" },
|
||||
{ from: "/console/engine/:id", to: "/console/tasks?run=:id", list: "/api/v1/engine/runs" },
|
||||
{ from: "/console/schedules", to: "/console/tasks?tab=plan" },
|
||||
// FE-6:计划从任务页拆回独立的计划页
|
||||
{ from: "/console/tasks?tab=plan", to: "/console/schedules" },
|
||||
{ from: "/console/tasks?tab=plan&job=trash.purge", to: "/console/schedules?job=trash.purge" },
|
||||
{ from: "/console/identity", to: "/console/library?filter=unidentified" },
|
||||
{ from: "/console/identity?tab=external", to: "/console/library?view=identities" },
|
||||
// 设置旧页签 ?tab= → 分区 hash(IA-2)
|
||||
|
||||
Reference in new issue
Block a user