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:
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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { ChevronDown, Pause, Play, ShieldAlert } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { useLocation } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useUpdateSettings } from "~/api/settings.ts";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { type FormValues, num } from "./resource-section.tsx";
|
||||
import { SettingsBlock } from "./settings-block.tsx";
|
||||
import { anchorId, SettingsBlock } from "./settings-block.tsx";
|
||||
import { type CardSpec, n, SettingsCard, SettingsGate } from "./settings-card.tsx";
|
||||
|
||||
// 自动化分区:总开关、只读对账、缓存水位、每日下载预算、保留天数、存储预算与保留(CACHE-2)
|
||||
@@ -15,12 +16,12 @@ const GiB = 1024 ** 3;
|
||||
|
||||
const CACHE_CARD: CardSpec = {
|
||||
anchor: "cache",
|
||||
title: "缓存水位",
|
||||
description: "缓存卷使用率到硬阈值时停止新增下载并暂停本工具的下载任务;降到恢复水位以下只恢复因阈值暂停的任务。",
|
||||
title: "下载盘",
|
||||
description: "下载盘用量到暂停线时不再新增下载,并暂停本工具的下载任务;降到恢复线以下只恢复因此暂停的任务。",
|
||||
fields: [
|
||||
{ name: "cache.hardThresholdPercent", label: "硬阈值(%)", kind: "number" },
|
||||
{ name: "cache.resumePercent", label: "恢复水位(%)", kind: "number", hint: "低于硬阈值" },
|
||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复水位" },
|
||||
{ name: "cache.hardThresholdPercent", label: "暂停线(%)", kind: "number" },
|
||||
{ name: "cache.resumePercent", label: "恢复线(%)", kind: "number", hint: "低于暂停线" },
|
||||
{ name: "cache.cleanupTargetPercent", label: "清理目标(%)", kind: "number", hint: "不高于恢复线" },
|
||||
{ name: "cache.checkIntervalSec", label: "检查间隔(秒)", kind: "number", hint: "10–3600" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
@@ -75,7 +76,7 @@ const RETENTION_CARD: CardSpec = {
|
||||
kind: "number",
|
||||
hint: "复制入库后下载源在缓存里至少留这么久;0–365",
|
||||
},
|
||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number", hint: "观察与日志类数据" },
|
||||
{ name: "retention.logRetentionDays", label: "日志保留天数", kind: "number" },
|
||||
],
|
||||
fromSettings: (s) => ({
|
||||
"retention.seedRetentionDays": n(s.retention.seedRetentionDays),
|
||||
@@ -211,6 +212,59 @@ function ToggleRow({
|
||||
);
|
||||
}
|
||||
|
||||
/** 高级:只读对账、存储预算、流水保留、缓存有效期;默认收起,从 #automation/advanced 进来时展开 */
|
||||
function Advanced({ children }: { children: ReactNode }) {
|
||||
const location = useLocation();
|
||||
const [open, setOpen] = useState(() => location.hash.endsWith("/advanced"));
|
||||
useEffect(() => {
|
||||
if (location.hash.endsWith("/advanced")) setOpen(true);
|
||||
}, [location.hash]);
|
||||
return (
|
||||
<section id={anchorId("automation", "advanced")} className="scroll-mt-20">
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="inline-flex items-center gap-1.5 text-lead font-semibold text-foreground"
|
||||
>
|
||||
高级
|
||||
<ChevronDown
|
||||
className={cn("size-4 text-muted-foreground transition-transform duration-base", open && "rotate-180")}
|
||||
/>
|
||||
</button>
|
||||
<p className="mt-1 text-caption text-muted-foreground">只读对账、存储预算、数据保留和缓存有效期。一般不用改。</p>
|
||||
{open ? <div className="mt-6 grid grid-cols-1 gap-8">{children}</div> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadOnlySwitch() {
|
||||
const update = useUpdateSettings();
|
||||
return (
|
||||
<SettingsGate>
|
||||
{(s) => (
|
||||
<SettingsBlock section="automation" anchor="readonly" title="只读对账">
|
||||
<ToggleRow
|
||||
id="tg-readonly"
|
||||
icon={<ShieldAlert className="h-4 w-4" />}
|
||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
||||
checked={s.automation.readOnlyReconcile}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) =>
|
||||
update.mutateAsync({ automation: { readOnlyReconcile: v } }).then(
|
||||
() => toast.success(v ? "已开启只读对账" : "已关闭只读对账"),
|
||||
(err) => toast.error(errorMessage(err, "切换失败")),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</SettingsBlock>
|
||||
)}
|
||||
</SettingsGate>
|
||||
);
|
||||
}
|
||||
|
||||
function Switches() {
|
||||
const update = useUpdateSettings();
|
||||
const set = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, msg: string) => {
|
||||
@@ -236,16 +290,6 @@ function Switches() {
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ enabled: v }, v ? "自动化已恢复" : "自动化已暂停")}
|
||||
/>
|
||||
<ToggleRow
|
||||
id="tg-readonly"
|
||||
icon={<ShieldAlert className="h-4 w-4" />}
|
||||
tone={s.automation.readOnlyReconcile ? "warning" : "success"}
|
||||
title={`只读对账 · ${s.automation.readOnlyReconcile ? "开启" : "关闭"}`}
|
||||
description="从备份恢复后先开启:只核对库存与路径,不做自动写入。核对完再关闭。"
|
||||
checked={s.automation.readOnlyReconcile}
|
||||
disabled={update.isPending}
|
||||
onChange={(v) => set({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账")}
|
||||
/>
|
||||
</div>
|
||||
</SettingsBlock>
|
||||
)}
|
||||
@@ -263,9 +307,12 @@ export function AutomationSection() {
|
||||
<SettingsCard section="automation" spec={CACHE_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={RETENTION_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
||||
<Advanced>
|
||||
<ReadOnlySwitch />
|
||||
<SettingsCard section="automation" spec={STORAGE_BUDGET_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_RETENTION_CARD} settings={s} />
|
||||
<SettingsCard section="automation" spec={STORAGE_TTL_CARD} settings={s} />
|
||||
</Advanced>
|
||||
</>
|
||||
)}
|
||||
</SettingsGate>
|
||||
|
||||
@@ -38,7 +38,7 @@ export const CLEANUP_LABEL: Record<StorageCleanupTarget, string> = {
|
||||
audit: "审计事件",
|
||||
capability_stats: "能力调用统计",
|
||||
identity_pool: "未识别资源归档",
|
||||
analyze: "统计信息更新",
|
||||
analyze: "数据库维护",
|
||||
images: "图片缓存容量",
|
||||
kv_cache: "过期缓存",
|
||||
};
|
||||
|
||||
@@ -94,7 +94,7 @@ export const RUN_STATUS_OPTIONS = ["pending", "running", "completed", "failed",
|
||||
export function runTitle(r: Pick<EngineRun, "business" | "workflowName">): string {
|
||||
const b = r.business;
|
||||
if (b?.title) return b.title;
|
||||
if (b) return `${BUSINESS_KIND_LABEL[b.kind]} ${b.refId.length > 12 ? b.refId.slice(0, 8) : b.refId}`;
|
||||
if (b) return BUSINESS_KIND_LABEL[b.kind];
|
||||
return workflowLabel(r.workflowName);
|
||||
}
|
||||
|
||||
|
||||
@@ -254,7 +254,7 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailSection title="业务摘要">
|
||||
<DetailSection title="概况">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
@@ -262,11 +262,11 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
value: b ? (
|
||||
link ? (
|
||||
<Link to={link.to} className="inline-flex items-center gap-1 underline underline-offset-4">
|
||||
{BUSINESS_KIND_LABEL[b.kind]} · {b.title ?? b.refId}
|
||||
{b.title ? `${BUSINESS_KIND_LABEL[b.kind]} · ${b.title}` : BUSINESS_KIND_LABEL[b.kind]}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
) : (
|
||||
`${BUSINESS_KIND_LABEL[b.kind]} · ${b.title ?? b.refId}`
|
||||
`${BUSINESS_KIND_LABEL[b.kind]}${b.title ? ` · ${b.title}` : ""}`
|
||||
)
|
||||
) : (
|
||||
"无"
|
||||
@@ -279,7 +279,6 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
label: "开始",
|
||||
value: r.startedAt ? `${formatDateTime(r.startedAt)}(${formatRelative(r.startedAt)})` : "未开始",
|
||||
},
|
||||
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
@@ -304,9 +303,11 @@ export function TaskDetailBody({ r, onOpenRun }: { r: EngineRunDetail; onOpenRun
|
||||
</ul>
|
||||
</DetailSection>
|
||||
|
||||
<Collapsible title={`引擎步骤(${r.stepAttempts.length})`}>
|
||||
<Collapsible title={`技术信息 · 引擎步骤(${r.stepAttempts.length})`}>
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "耗时", value: formatDuration(spanSec(r)) },
|
||||
{ label: "关联对象 ID", value: b?.refId ?? null, mono: true },
|
||||
{ label: "工作流", value: `${r.workflowName}${r.version ? ` ${r.version}` : ""}`, mono: true },
|
||||
{ label: "运行 ID", value: r.id, mono: true },
|
||||
{ label: "尝试次数", value: r.attempts },
|
||||
|
||||
@@ -10,7 +10,7 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { RunsSheet } from "~/components/schedules/runs-sheet.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { formatDuration, formatRelative, spanSec } from "~/lib/labels.ts";
|
||||
import { formatRelative } from "~/lib/labels.ts";
|
||||
import { qk } from "~/lib/query-keys.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import {
|
||||
@@ -70,6 +70,8 @@ export type StreamFilters = {
|
||||
source: string;
|
||||
since: string;
|
||||
workflow: string;
|
||||
/** "1" = 显示系统任务 */
|
||||
system: string;
|
||||
};
|
||||
|
||||
function SourceBadge({ r }: { r: EngineRun }) {
|
||||
@@ -146,8 +148,13 @@ export function TaskStream({
|
||||
true,
|
||||
active ? 10_000 : false,
|
||||
);
|
||||
const rows = list.data?.pages.flatMap((p) => p.items);
|
||||
const filtered = Object.values(filters).some(Boolean);
|
||||
const all = list.data?.pages.flatMap((p) => p.items);
|
||||
// 系统任务(没有业务对象的引擎步骤、定时任务的成功运行)默认不列;按对象 / 定时任务筛选或勾选时显示
|
||||
const showSystem = filters.system === "1" || !!filters.ref || filters.kind === "schedule" || !!filters.workflow;
|
||||
const isSystem = (r: EngineRun) => !r.business || (r.business.kind === "schedule" && r.status === "completed");
|
||||
const rows = showSystem ? all : all?.filter((r) => !isSystem(r));
|
||||
const hidden = (all?.length ?? 0) - (rows?.length ?? 0);
|
||||
const filtered = Object.entries(filters).some(([k, v]) => k !== "system" && !!v);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -196,6 +203,14 @@ export function TaskStream({
|
||||
options={SINCE}
|
||||
allLabel="不限"
|
||||
/>
|
||||
<Button
|
||||
variant={filters.system === "1" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
aria-pressed={filters.system === "1"}
|
||||
onClick={() => setFilter("system", filters.system === "1" ? "" : "1")}
|
||||
>
|
||||
{filters.system === "1" ? "隐藏系统任务" : hidden ? `显示系统任务(${hidden})` : "显示系统任务"}
|
||||
</Button>
|
||||
{filters.workflow ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setFilter("workflow", "")}>
|
||||
{workflowLabel(filters.workflow)}
|
||||
@@ -234,11 +249,9 @@ export function TaskStream({
|
||||
cell: (r) => (
|
||||
<div className="min-w-0 max-w-[15rem] sm:max-w-sm">
|
||||
<p className="truncate font-medium text-foreground">{runTitle(r)}</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{r.business
|
||||
? `${workflowLabel(r.workflowName)} · ${BUSINESS_KIND_LABEL[r.business.kind]}`
|
||||
: `未关联业务对象 · ${r.id.slice(0, 8)}`}
|
||||
</p>
|
||||
{r.business?.title ? (
|
||||
<p className="truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
@@ -253,7 +266,7 @@ export function TaskStream({
|
||||
},
|
||||
{
|
||||
key: "time",
|
||||
header: "开始 / 耗时",
|
||||
header: "开始",
|
||||
hideBelow: "sm",
|
||||
cell: (r) => (
|
||||
<div className="whitespace-nowrap">
|
||||
@@ -264,7 +277,6 @@ export function TaskStream({
|
||||
? `排队 ${formatRelative(r.createdAt)}`
|
||||
: `未开始 · ${formatRelative(r.createdAt)}`}
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground tabular-nums">{formatDuration(spanSec(r))}</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user