feat(web): 首页只回答三件事、计划页巡检置顶与分组折叠、任务列表去噪(DEAI-2)

首页:顶部一行状态(自动化开关 + 只在异常时出现的服务 / 下载盘 / 站点账号 / 下载器问题),
下面只有「在忙什么」「要我处理」「最近入库」三块;去掉运行状态、计划、缓存、来源健康、
满足率与 0 值铺陈;0 订阅时写「还没有订阅。去搜索添加」。只读对账移出首页,设置里放进
自动化 →「高级」(与存储预算、流水保留、缓存有效期一起默认收起)。

计划页:「智能体巡检」移到最上面——巡检数量少、由用户自己建、每次会花钱,是这页最需要
看的;自动任务任务多、多数不用管,放在下面。自动任务按分类分组、每组可折叠:有失败或
运行中的组默认展开,其余收起为「N 个任务 · 下次 xx」一行,折叠状态记在 localStorage;
维护类整组叫「数据清理」,未接入调度的备份不列。巡检卡只留名称、开关、间隔、上次结果与
费用,目标 / 能力 / 预算 / 模型 / 最近会话收进「详情」。去掉顶部 6 个计数卡。定时任务说明
去掉表名、字段名与实现备注。

任务页:默认隐藏系统任务(无业务对象的引擎步骤、定时任务的成功运行),可一键显示;
标题不再用 ID;耗时移入技术信息;< 1 秒写「< 1 秒」;费用两位小数,不足一分写「< $0.01」。
下载盘相关文案改用「暂停线 / 恢复线」。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 20:48:35 +08:00
1 parent f0d4935fa7
commit 260f3ab27f
23 files changed
+559 -816

No files matched your search

@@ -7,6 +7,7 @@ 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 { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -16,7 +17,8 @@ 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),一个巡检一张卡片。
// 计划页「智能体巡检」:到点按目标模板发起一次智能体会话(kind=agent_check),一个巡检一张卡片。
// 卡片默认只显示名称、开关、间隔、上次结果与费用;目标、能力、预算、模型、最近会话收进「详情」。
/** 卡片上能力白名单最多列几个,其余折成"+N" */
const MAX_CAPS = 6;
@@ -113,103 +115,31 @@ export function AgentCheckCard({
/>
</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 className="flex flex-wrap items-center gap-x-4 gap-y-2 text-meta text-muted-foreground">
<IntervalSelect
label={`${job.name}间隔`}
schedule={job.schedule}
disabled={!job.available}
pending={a.update.isPending}
onSave={a.setSchedule}
/>
<span className="inline-flex items-center gap-1">
下次 <NextRun job={job} now={now} />
</span>
</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>
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
{last ? (
job.lastStatus === "ok" ? (
<span>上次成功</span>
) : (
"全部只读能力"
)}
{c.allowRecover ? <span className="ml-1 text-meta text-muted-foreground">· 允许授权内恢复</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="模型服务">
{stats?.lastModel ? (
<span className="break-words" title={modelsTitle(stats.models)}>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
) : null}
</span>
) : upstream ? (
<span
className="break-words"
title={
upstream.modelFromCatalog
? "模型服务没有指定默认模型,运行时使用模型目录里的第一个"
: "还没有运行记录,按默认模型服务显示"
}
>
默认模型服务 {upstream.name} · {upstream.model ?? "模型未指定"}
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
</span>
) : (
<span className="text-muted-foreground">未配置模型服务</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="每次费用">
{stats?.count ? (
<span className="tabular-nums">
上次 {formatUsd(stats.lastCostUsd)}
<span className="text-muted-foreground">
{" "}
· 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</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>
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
)
) : (
<span>还没运行过</span>
)}
{job.lastRunAt ? <span title={job.lastRunAt}>{formatRelative(job.lastRunAt)}</span> : null}
{stats?.count ? <span className="tabular-nums">· 每次约 {formatUsd(stats.avgCostUsd)}</span> : null}
</p>
{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">
@@ -218,33 +148,96 @@ export function AgentCheckCard({
</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"
<TechInfo title="详情" columns={1}>
<dl className="grid grid-cols-[4.5rem_minmax(0,1fr)] items-baseline gap-x-3 gap-y-1.5">
<Field label="目标">
<span className="line-clamp-4 break-words" title={c.goalTemplate}>
{c.goalTemplate ?? "—"}
</span>
</Field>
<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"
>
<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.costUsd)}</span>
<span className="shrink-0" title={r.createdAt}>
{formatRelative(r.finishedAt ?? r.createdAt)}
</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
{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="触发条件">
{CHECK_TRIGGER_LABEL[c.triggerCondition ?? "always"]}
{c.reportAsDecision ? <span className="ml-1 text-meta text-muted-foreground">· 结果写入待处理</span> : null}
</Field>
<Field label="预算">{budgetParts.length ? budgetParts.join(" · ") : "默认"}</Field>
<Field label="模型">
{stats?.lastModel ? (
<span className="break-words" title={modelsTitle(stats.models)}>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
{stats.lastModel}
{stats.models.length > 1 ? (
<span className="text-muted-foreground"> 等 {stats.models.length} 个模型</span>
) : null}
</span>
) : upstream ? (
<span className="break-words">
{upstream.name} · {upstream.model ?? "模型未指定"}
</span>
) : (
<span className="text-muted-foreground">未配置模型服务</span>
)}
</Field>
{stats?.count ? (
<Field label="费用">
<span className="tabular-nums">
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</span>
</Field>
) : null}
</dl>
{runs.length ? (
<section aria-label="最近会话" className="grid min-w-0 gap-1">
<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 st = 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"
>
{r.status === "succeeded" ? null : <StatusBadge tone={st.tone}>{st.label}</StatusBadge>}
<span className="min-w-0 flex-1 truncate" title={r.summary ?? undefined}>
{plainSummary(r.summary ?? r.goal)}
</span>
<span className="shrink-0" title={r.createdAt}>
{formatRelative(r.finishedAt ?? r.createdAt)}
</span>
</Link>
</li>
);
})}
</ul>
</section>
) : null}
</TechInfo>
<footer className="flex flex-wrap items-center gap-1.5">
<Button size="sm" disabled={!a.canRun} onClick={a.run}>
@@ -1,6 +1,6 @@
import type { ScheduledJob, ScheduleGroup, ScheduleSpecInput } from "@umt/contracts";
import { History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
import { useEffect, useRef } from "react";
import { ChevronRight, History, LoaderCircle, Play, RotateCcw, SkipForward } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useRunScheduleNow, useSkipSchedule, useUpdateSchedule } from "~/api/schedules.ts";
@@ -9,11 +9,12 @@ 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 { readCollapseState, writeCollapseState } from "~/lib/ui-prefs.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),按组的紧凑列表。
// 计划页「自动任务」:程序按固定周期执行的定时任务(kind=task),按组折叠的紧凑列表;维护类一组叫「数据清理」。
/** 下次运行:运行中 > 排队中 > 未配置 > 已停用 > 倒计时 */
export function NextRun({ job, now }: { job: ScheduledJob; now: number }) {
@@ -121,9 +122,22 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
/>
</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}
{last ? (
job.lastStatus === "ok" ? (
<span>成功</span>
) : (
<StatusBadge tone={last.tone}>{last.label}</StatusBadge>
)
) : (
<span>未运行</span>
)}
{job.lastRunAt ? (
<span
title={`${job.lastRunAt}${job.lastDurationMs !== null ? ` · 用时 ${formatMs(job.lastDurationMs)}` : ""}`}
>
{formatRelative(job.lastRunAt)}
</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>
@@ -178,6 +192,25 @@ function AutoTaskRow({ job, now, highlight }: { job: ScheduledJob; now: number;
);
}
const COLLAPSE_KEY = "umt.schedules.groups";
/** 组摘要:"6 个任务 · 下次 3 分后 · 1 个失败" */
function groupSummary(list: ScheduledJob[], now: number): string {
const next = list
.filter((j) => j.enabled && j.available && j.nextRunAt)
.map((j) => j.nextRunAt as string)
.sort()[0];
const failed = list.filter((j) => j.lastStatus === "failed").length;
const running = list.filter((j) => j.runningSince).length;
return [
`${list.length} 个任务`,
running ? `${running} 个运行中` : next ? `下次 ${countdown(next, now)}` : null,
failed ? `${failed} 个失败` : null,
]
.filter(Boolean)
.join(" · ");
}
export function AutoTaskList({
jobs,
now,
@@ -189,37 +222,73 @@ export function AutoTaskList({
}) {
const byGroup = new Map<ScheduleGroup, ScheduledJob[]>();
for (const j of jobs) {
// 未接入调度的占位任务(如备份)不列
if (!j.available && !j.enabled && j.group === "maintain") continue;
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);
// 折叠:有失败 / 运行中 / 被高亮的组默认展开,其余收起;点过的按用户的来并记在本机
const [saved, setSaved] = useState<Record<string, boolean>>(() => readCollapseState(COLLAPSE_KEY) ?? {});
const expanded = (g: ScheduleGroup, list: ScheduledJob[]) =>
list.some((j) => j.id === highlightJob) ||
(saved[g] ?? list.some((j) => j.lastStatus === "failed" || !!j.runningSince));
const toggle = (g: ScheduleGroup, v: boolean) =>
setSaved((cur) => {
const next = { ...cur, [g]: v };
writeCollapseState(COLLAPSE_KEY, next);
return next;
});
const anyOpen = groups.some((g) => expanded(g, byGroup.get(g) ?? []));
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>
{anyOpen ? (
<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>
) : null}
{groups.map((g) => {
const list = byGroup.get(g) ?? [];
const open = expanded(g, list);
const failed = list.some((j) => j.lastStatus === "failed");
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>
<section key={g} aria-label={GROUP_LABEL[g]} className="border-b border-border/70 last:border-b-0">
<h3>
<button
type="button"
aria-expanded={open}
onClick={() => toggle(g, !open)}
className="flex w-full cursor-pointer flex-wrap items-baseline gap-x-3 gap-y-0.5 bg-foreground/[0.03] px-4 py-2 text-left transition-colors duration-micro hover:bg-foreground/[0.05]"
>
<ChevronRight
aria-hidden
className={cn(
"size-3.5 shrink-0 self-center text-muted-foreground transition-transform duration-base",
open && "rotate-90",
)}
/>
<span className="text-body font-medium text-foreground">{GROUP_LABEL[g]}</span>
<span className={cn("text-meta tabular-nums", failed ? "text-destructive" : "text-muted-foreground")}>
{groupSummary(list, now)}
</span>
</button>
</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>
{open ? (
<ul className="divide-y divide-border/70 border-t border-border/70">
{list.map((j) => (
<AutoTaskRow key={j.id} job={j} now={now} highlight={highlightJob === j.id} />
))}
</ul>
) : null}
</section>
);
})}
@@ -12,10 +12,11 @@ describe("计划页标签", () => {
expect(scheduleStreamPath("trash.purge")).toBe("/console/tasks?tab=all&kind=schedule&ref=trash.purge");
});
it("formatUsd:小额保留 4 位", () => {
it("formatUsd:两位小数,不足一分写 < $0.01", () => {
expect(formatUsd(undefined)).toBe("—");
expect(formatUsd(0)).toBe("$0");
expect(formatUsd(0.0031258)).toBe("$0.0031");
expect(formatUsd(0.0031258)).toBe("< $0.01");
expect(formatUsd(0.0191)).toBe("$0.02");
expect(formatUsd(1.234)).toBe("$1.23");
});
+5 -4
View File
@@ -10,7 +10,7 @@ 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 */
@@ -28,7 +28,7 @@ export const GROUP_LABEL: Record<ScheduleGroup, string> = {
collect: "采集",
download: "下载",
organize: "整理",
maintain: "维护",
maintain: "数据清理",
agent: "智能体巡检",
};
@@ -70,11 +70,12 @@ export const INTERVAL_PRESETS: number[] = [
7 * 24 * 3600_000,
];
/** 美元费用:"$0.0031" / "$0.12";空值 "—" */
/** 美元费用:两位小数,不足一分写 "< $0.01";空值 "—" */
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)}`;
if (v < 0.01) return "< $0.01";
return `$${v.toFixed(2)}`;
}
/** 毫秒 → 最大整除单位 */
@@ -1,12 +1,11 @@
import { useQueryClient } from "@tanstack/react-query";
import { SCHEDULE_CHANGED_EVENT, type ScheduledJob } from "@umt/contracts";
import { AlertTriangle, Bot, CalendarClock, CircleCheck, Coins, LoaderCircle, Plus, XCircle } from "lucide-react";
import { AlertTriangle, Bot, CalendarClock, Plus } from "lucide-react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useLocation } from "react-router";
import { usePiUpstreamModels, 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";
@@ -16,8 +15,9 @@ import { AutoTaskList } from "./auto-task-list.tsx";
import { formatUsd } from "./labels.ts";
import { RunsSheet } from "./runs-sheet.tsx";
// 计划页(FE-6,/console/schedules):「自动任务」= 程序按固定周期执行的定时任务(紧凑列表);
// 「Agent 巡检」= 到点发起 Agent 会话的定时任务(卡片)。自动任务的运行记录跳任务页按该定时任务筛选。
// 计划页(FE-6,/console/schedules):「智能体巡检」= 到点发起智能体会话的定时任务(卡片,放最上面:
// 数量少、由用户自己建、会花钱,是这页最需要看的);「自动任务」= 程序按固定周期执行的定时任务
// (按分类折叠的紧凑列表,维护类合成「数据清理」)。自动任务的运行记录跳任务页按该定时任务筛选。
/** 每秒一跳的当前时间(倒计时用) */
export function useNow(ms = 1000) {
@@ -69,7 +69,17 @@ export function planStats(items: ScheduledJob[]) {
};
}
function SectionHead({ id, title, hint, actions }: { id: string; title: string; hint: string; actions?: ReactNode }) {
function SectionHead({
id,
title,
hint,
actions,
}: {
id: string;
title: string;
hint: ReactNode;
actions?: ReactNode;
}) {
return (
<div className="mb-3 flex flex-wrap items-end gap-x-3 gap-y-2">
<div className="min-w-0">
@@ -107,7 +117,6 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
}
: null;
const s = planStats(items);
const val = (n: number) => (board.isPending ? "…" : board.isError ? "—" : n);
// 首页计数链到 #auto / #checks:数据到了再滚过去
const loaded = !!data;
@@ -117,59 +126,8 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
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-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">智能体巡检</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={data ? formatUsd(s.checks.weekCost) : "…"}
hint={data ? `${s.checks.weekRuns} 次会话` : undefined}
onClick={() => jump("checks")}
/>
</div>
</div>
</div>
{data && !data.schedulerOnline ? (
<p
role="status"
@@ -186,24 +144,21 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
<ErrorState error={board.error} onRetry={() => board.refetch()} />
) : (
<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="智能体巡检"
hint="到点按目标发起一次智能体会话,只在能力白名单和预算内执行;触发条件不满足时跳过,不调用模型。"
hint={
s.checks.total
? [
`${s.checks.enabled} 个启用`,
s.checks.failed ? `${s.checks.failed} 个上次失败` : null,
s.checks.weekRuns ? `近 7 天 ${s.checks.weekRuns} 次,${formatUsd(s.checks.weekCost)}` : null,
]
.filter(Boolean)
.join(" · ")
: "到点让智能体按目标检查并汇报。"
}
actions={
<Button size="sm" onClick={() => setEditing({ job: null, key: Date.now() })}>
<Plus />
@@ -229,6 +184,24 @@ export function PlanBoard({ highlightJob }: { highlightJob?: string }) {
</div>
)}
</section>
<section aria-labelledby="auto">
<SectionHead
id="auto"
title="自动任务"
hint={[
`${s.auto.enabled} 个启用`,
s.auto.running ? `${s.auto.running} 个运行中` : null,
s.auto.failed ? `${s.auto.failed} 个上次失败` : null,
]
.filter(Boolean)
.join(" · ")}
/>
{autoJobs.length === 0 ? (
<EmptyState icon={CalendarClock} title="还没有自动任务" description="worker 启动后自动登记。" />
) : (
<AutoTaskList jobs={autoJobs} now={now} highlightJob={highlightJob} />
)}
</section>
</div>
)}