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
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user