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:
rootandClaude Opus 5.5 committed 2026-09-29 17:13:54 +08:00
1 parent c3aaf901ad
commit baf2d6ea04
22 files changed
+1030 -417

No files matched your search

+69 -67
View File
@@ -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>
);