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
+467 -724

No files matched your search

@@ -106,7 +106,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.sync",
defaults: {
name: "qB 同步",
description: "同步下载器任务状态,按缓存阈值暂停或恢复",
description: "同步下载器任务状态;下载盘到暂停线时暂停,降到恢复线后恢复",
group: "download",
schedule: every(syncMs),
},
@@ -125,8 +125,8 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
registerScheduledJob(ctx, {
key: "cache.threshold",
defaults: {
name: "缓存阈值检查",
description: "下载缓存超过硬阈值时入队清理",
name: "下载盘检查",
description: "下载盘超过暂停线时清理已入库的下载",
group: "maintain",
schedule: every(planMs),
},
@@ -141,7 +141,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "downloads.orchestrate",
defaults: {
name: "补提交与补入库",
description: "补投已预留未提交的下载、已完成未入库的任务",
description: "补交没提交成功的下载,补整理下载完但没入库的任务",
group: "download",
schedule: every(planMs),
},
@@ -155,7 +155,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "slots.reevaluate",
defaults: {
name: "到期重评",
description: "到了重评时间的收录位重新评估(忙时延后的发布、定时)",
description: "重新检查等待中的资源",
group: "download",
schedule: every(planMs),
},
@@ -165,7 +165,7 @@ export function createAcquisitionOrganizeModule(overrides: AoOverrides = {}): Se
key: "subscriptions.search",
defaults: {
name: "订阅周期搜索",
description: "到期的订阅主动搜索站点",
description: "到期的订阅去站点搜索",
group: "collect",
schedule: every(planMs),
},
@@ -52,7 +52,7 @@ export function createAuthSettingsModule(opts: AuthSettingsModuleOptions = {}):
key: "auth.cleanup",
defaults: {
name: "登录会话清理",
description: "删除 7 天前的登录尝试和 30 天前过期或吊销的会话",
description: "删除 7 天前的登录尝试和 30 天前失效的会话",
group: "maintain",
schedule: { type: "interval", everyMs },
},
+3 -3
View File
@@ -183,7 +183,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
key: "cache.sweep",
defaults: {
name: "缓存清理",
description: "删除 PG 缓存里已过期的条目(TMDB 请求与识别缓存)",
description: "删除过期的 TMDB 与识别缓存",
group: "maintain",
schedule: { type: "interval", everyMs: 10 * 60_000 },
firstRun: "after_interval",
@@ -194,7 +194,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
key: "cache.images_clean",
defaults: {
name: "图片缓存清理",
description: "删除超过保留天数的图片代理缓存文件(对照 MP 启动时的 clear_temp)",
description: "删除过期的海报缓存",
group: "maintain",
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
},
@@ -208,7 +208,7 @@ export function createCacheModule(opts: CacheModuleOptions = {}): ServerModule &
key: "cache.images_lru",
defaults: {
name: "图片缓存容量控制",
description: "图片代理缓存超过容量预算(存储设置,缺省 20 GB)时,按最久未访问删除到预算的 90%",
description: "海报缓存超过容量上限时,删掉最久没用的",
group: "maintain",
schedule: { type: "interval", everyMs: 30 * 60_000 },
firstRun: "after_interval",
+1 -1
View File
@@ -74,7 +74,7 @@ export function createPiModule(
key: "pi.token_refresh",
defaults: {
name: "模型服务登录刷新",
description: "订阅登录即将过期的模型服务,提前刷新 token",
description: "订阅登录快过期时提前续期",
group: "maintain",
schedule: { type: "interval", everyMs: interval },
},
+1 -1
View File
@@ -37,7 +37,7 @@ export function createSchedulesModule(opts: SchedulesModuleOptions = {}): Server
key: "maintain.backup",
defaults: {
name: "备份",
description: "数据库与数据目录备份;目前用 umt backup(宿主机 cron 调用),未接入调度",
description: "数据库与数据目录备份,目前在服务器上用命令执行",
group: "maintain",
schedule: { type: "cron", cron: "30 3 * * *" },
enabled: false,
@@ -61,7 +61,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
return { summary: `${await fn(rt)} 项` };
},
});
job("feeds.poll", "Feed 轮询", "扫描到期的 Feed 并入队抓取(每个 Feed 仍按自己的轮询间隔)", tickFeeds);
job("feeds.poll", "RSS 轮询", "按各 RSS 自己的间隔抓取新资源", tickFeeds);
job("sites.snapshot", "站点快照", "扫描到周期的站点并入队快照", tickSnapshots);
job("identity.review", "未识别复查", "到期的未识别条目重新识别", tickReviews);
// W9-A 站点中心:用户数据每 6 小时、健康检测每小时(可在定时任务页关闭或改周期)
@@ -69,7 +69,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: "sites.user_data",
defaults: {
name: "站点数据刷新",
description: "登录状态正常的站点账号刷新上传 / 下载 / 分享率等用户数据;会话失效的账密账号自动重登一次",
description: "刷新站点账号的上传、下载和分享率;登录失效的账密账号自动重登一次",
group: "collect",
schedule: { type: "interval", everyMs: 6 * 3_600_000 },
firstRun: "after_interval",
@@ -83,7 +83,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: "sites.health_check",
defaults: {
name: "站点健康检测",
description: "已启用站点逐个检测首页连通与登录态,结果显示在站点页",
description: "检查到期的站点能否打开、是否还在登录(每个站点按自己的检测间隔),结果在站点页",
group: "collect",
schedule: { type: "interval", everyMs: 3_600_000 },
firstRun: "after_interval",
@@ -95,8 +95,7 @@ export function createSourcesIdentityModule(overrides: RuntimeOverrides = {}): S
key: BACKFILL_LANGUAGES_JOB,
defaults: {
name: "元数据语言回刷",
description:
"对已确认 TMDB 身份的作品按语言回退链完整刷新一次(标题、简介、每集信息),TMDB 每秒最多 2 次请求;跑完自动停用,可手动再执行",
description: "按语言设置重新拉一遍作品的标题、简介和每集信息;跑完自动停用,可手动再执行",
group: "maintain",
schedule: { type: "interval", everyMs: 24 * 3_600_000 },
firstRun: "now",
+1 -1
View File
@@ -210,7 +210,7 @@ export function createSystemModule(opts: SystemModuleOptions = {}): ServerModule
key: "system.heartbeat_cleanup",
defaults: {
name: "进程心跳清理",
description: "删除 24 小时没有心跳的进程行和超过保留期(存储设置,缺省 24 小时)的心跳采样",
description: "删除过期的进程状态记录",
group: "maintain",
schedule: { type: "interval", everyMs },
},
+11 -11
View File
@@ -105,8 +105,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "observation",
jobId: "storage.retention.observation",
name: "观察记录清理",
description: "删除超过保留期的 Feed 观察记录(observation,按 created_at)",
name: "RSS 抓取记录清理",
description: "删除过期的 RSS 抓取记录",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.observation} 天`,
async run(c) {
@@ -118,8 +118,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "sse_event",
jobId: "storage.retention.sse_event",
name: "SSE 事件清理",
description: "删除超过保留期的 SSE 补读事件(sse_event,按 at;发布方另按条数裁剪)",
name: "实时事件清理",
description: "删除过期的页面实时推送记录",
everyMs: HOUR,
retention: (s) => `${s.retentionDays.sseEvent} 天`,
async run(c) {
@@ -132,7 +132,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "task_run",
jobId: "storage.retention.task_run",
name: "任务运行清理",
description: "删除超过保留期、已结束的业务任务(task)与引擎 run(含步骤、运行元数据)",
description: "删除过期的已结束任务",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.taskRun} 天`,
async run(c) {
@@ -168,7 +168,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "agent_event",
jobId: "storage.retention.agent_event",
name: "智能体事件清理",
description: "删除超过保留期、会话已结束的智能体事件流与消息(agent_run_event / agent_run_message;会话本身保留)",
description: "删除已结束会话的过期消息,会话本身保留",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.agentEvent} 天`,
async run(c) {
@@ -189,7 +189,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "audit",
jobId: "storage.retention.audit",
name: "审计清理",
description: "删除超过保留期的审计事件(audit_event,能力调用审计按能力统计的保留期另算)",
description: "删除过期的审计日志",
everyMs: 24 * HOUR,
retention: (s) => `${s.retentionDays.audit} 天`,
async run(c) {
@@ -207,7 +207,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "capability_stats",
jobId: "storage.retention.capability_stats",
name: "能力统计清理",
description: "删除超过保留期的能力调用审计(audit_event target_type=capability)与已结束的能力调用记录",
description: "删除过期的能力调用记录",
everyMs: 24 * HOUR,
retention: (s) => `${s.retentionDays.capabilityStats} 天`,
async run(c) {
@@ -231,7 +231,7 @@ export const RETENTION_JOBS: RetentionJob[] = [
target: "identity_pool",
jobId: "storage.retention.identity_pool",
name: "未识别资源归档",
description: "未识别资源里 unmatched 条目超过保留期没有新证据就归档(不删除,仍可按状态筛出)",
description: "长期没有新线索的未识别资源归档,仍可手动指定",
everyMs: 6 * HOUR,
retention: (s) => `${s.retentionDays.identityPoolArchive} 天`,
async run(c) {
@@ -252,8 +252,8 @@ export const RETENTION_JOBS: RetentionJob[] = [
{
target: "analyze",
jobId: "storage.analyze",
name: "统计信息更新",
description: "对全库执行 ANALYZE,刷新查询规划用的统计信息(每周)",
name: "数据库维护",
description: "每周刷新数据库统计信息",
everyMs: 7 * 24 * HOUR,
retention: () => null,
async run(c) {
@@ -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,13 +115,46 @@ 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>
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 text-meta text-muted-foreground">
{last ? (
job.lastStatus === "ok" ? (
<span>上次成功</span>
) : (
<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">
<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}
<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">
@@ -146,8 +181,12 @@ export function AgentCheckCard({
)}
{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="模型服务">
<Field label="模型">
{stats?.lastModel ? (
<span className="break-words" title={modelsTitle(stats.models)}>
{stats.lastProvider ? `${stats.lastProvider} · ` : ""}
@@ -157,84 +196,37 @@ export function AgentCheckCard({
) : null}
</span>
) : upstream ? (
<span
className="break-words"
title={
upstream.modelFromCatalog
? "模型服务没有指定默认模型,运行时使用模型目录里的第一个"
: "还没有运行记录,按默认模型服务显示"
}
>
默认模型服务 {upstream.name} · {upstream.model ?? "模型未指定"}
{upstream.modelFromCatalog ? <span className="text-muted-foreground">(目录首个)</span> : null}
<span className="break-words">
{upstream.name} · {upstream.model ?? "模型未指定"}
</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 ? (
<Field label="费用">
<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}
上次 {formatUsd(stats.lastCostUsd)} · 近 {stats.count} 次平均 {formatUsd(stats.avgCostUsd)}
</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}
</dl>
{runs.length ? (
<section aria-label="最近会话" className="grid min-w-0 gap-1 border-t border-border/70 pt-2.5">
<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 s = agentRunLabel(r.status);
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"
>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
{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 tabular-nums">{formatUsd(r.costUsd)}</span>
<span className="shrink-0" title={r.createdAt}>
{formatRelative(r.finishedAt ?? r.createdAt)}
</span>
@@ -245,6 +237,7 @@ export function AgentCheckCard({
</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,13 +222,28 @@ 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">
{anyOpen ? (
<div
aria-hidden
className={cn("hidden border-b border-border/70 px-4 py-2 text-meta text-muted-foreground lg:grid", COLS)}
@@ -207,19 +255,40 @@ export function AutoTaskList({
<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">
{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>
)}
+66 -19
View File
@@ -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} />
<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: "过期缓存",
};
+1 -1
View File
@@ -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 },
+22 -10
View File
@@ -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>
),
},
+1 -1
View File
@@ -97,7 +97,7 @@ export const engineRunLabel = (v: string | null | undefined) => pick(ENGINE_RUN,
/** 相对时长:秒 → "3 分钟" */
export function formatDuration(sec: number | null | undefined): string {
if (sec === null || sec === undefined || Number.isNaN(sec)) return "—";
if (sec < 1) return `${Math.round(sec * 1000)} 毫秒`;
if (sec < 1) return "< 1 秒";
if (sec < 60) return `${Math.round(sec)} 秒`;
if (sec < 3600) return `${Math.floor(sec / 60)} 分 ${Math.round(sec % 60)} 秒`;
if (sec < 86400) return `${Math.floor(sec / 3600)} 小时 ${Math.floor((sec % 3600) / 60)} 分`;
+152 -503
View File
@@ -5,18 +5,12 @@ import {
AlertTriangle,
ArrowRight,
Bot,
CalendarClock,
CalendarCog,
CircleCheck,
Gauge,
HardDrive,
Download,
Library as LibraryIcon,
ListTodo,
type LucideIcon,
Pause,
Play,
Radio,
ShieldAlert,
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef } from "react";
@@ -24,31 +18,27 @@ import { Link } from "react-router";
import { toast } from "sonner";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.ts";
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
import { useEngineRunsPage } from "~/api/engine.ts";
import { useUpdateSettings } from "~/api/settings.ts";
import { useSystemHealth } from "~/api/system.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath, settingsPath } from "~/components/console/nav.ts";
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 { formatUsd, planPath } from "~/components/schedules/labels.ts";
import { planStats, useLiveSchedules } from "~/components/schedules/schedule-board.tsx";
import { engineRunStatus, runTitle, workflowLabel } from "~/components/tasks/labels.ts";
import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } from "~/lib/labels.ts";
import { decisionKindLabel, formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
// 首页只回答三件事(DEAI-2):在忙什么、要我处理什么、最近入库了什么;顶部一行一句话状态。
// 运行状态、计划、缓存参数、收录版本满足率不在这里,只在出问题时写进状态行。
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
"schedule.changed",
"task.created",
"task.status_changed",
"download.submitted",
@@ -56,8 +46,6 @@ const DASHBOARD_EVENTS = [
"download.threshold",
"organization.changed",
"agent.run_status",
"feed.fetched",
"feed.health_changed",
"decision.created",
"decision.updated",
"settings.changed",
@@ -82,28 +70,24 @@ function Section({
to,
children,
className,
extra,
}: {
title: string;
icon: LucideIcon;
to?: string;
children: ReactNode;
className?: string;
/** 标题右侧的摘要(计数、状态) */
extra?: ReactNode;
}) {
return (
<section className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-5", className)}>
<div className="mb-3 flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<Icon className="h-4 w-4 text-muted-foreground" aria-hidden />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
{extra}
{to ? (
<Link
to={to}
className="ml-auto inline-flex items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
查看
全部
<ArrowRight className="h-3.5 w-3.5" />
</Link>
) : null}
@@ -117,144 +101,170 @@ function Row({ children }: { children: ReactNode }) {
return <li className="flex min-w-0 items-center justify-between gap-3 py-1.5 text-sm">{children}</li>;
}
// ─── 自动化总开关:显眼状态 + 切换 ───
function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
/** 非零项拼一句:[[3, "个下载"], [0, "个入库中"]] → "3 个下载" */
function nonZero(parts: [number, string][]): string {
return parts
.filter(([n]) => n > 0)
.map(([n, s]) => `${n} ${s}`)
.join(" · ");
}
// ─── 顶部一行:自动化状态 + 需要知道的异常 ───
function StatusLine({ d }: { d: Dashboard }) {
const a = d.automation;
const update = useUpdateSettings();
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
const health = useSystemHealth(60_000);
const sites = useSitesWithAccounts();
const setEnabled = async (enabled: boolean) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(ok);
await update.mutateAsync({ automation: { enabled } });
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
};
const paused = !a.enabled;
const readOnly = a.readOnlyReconcile;
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
const problems: { text: string; to: string }[] = [];
const lv = health.data?.level;
if (lv === "critical" || lv === "warn")
problems.push({ text: lv === "critical" ? "服务异常" : "服务需要关注", to: consolePath("status") });
for (const v of d.cache.volumes)
if (v.usedPercent !== null && v.usedPercent >= v.hardThresholdPercent)
problems.push({ text: `下载盘已用 ${Math.round(v.usedPercent)}%,新下载已暂停`, to: consolePath("downloads") });
const badAccounts = (sites.data?.items ?? []).filter(
(s) => s.enabled && s.accountStatus && ["invalid", "expired", "challenge"].includes(s.accountStatus),
).length;
if (badAccounts) problems.push({ text: `${badAccounts} 个站点账号需要重新登录`, to: consolePath("sites") });
const offline = d.sources.downloaders.filter((x) => x.enabled && ["offline", "failing"].includes(x.health)).length;
if (offline) problems.push({ text: `${offline} 个下载器连不上`, to: settingsPath("downloaders") });
const paused = !a.enabled;
const warn = paused || a.readOnlyReconcile || problems.length > 0;
return (
<div
className={cn(
"pg-slab mb-4 flex flex-col gap-4 rounded-xl p-4 sm:flex-row sm:items-center sm:p-5",
(paused || readOnly) && "border border-warning/40",
"pg-slab mb-4 flex flex-col gap-3 rounded-xl px-4 py-3 sm:flex-row sm:items-center sm:px-5",
warn && "border border-warning/40",
)}
>
<div className="flex min-w-0 flex-1 items-start gap-3">
<span
className={cn(
"grid h-10 w-10 shrink-0 place-items-center rounded-full",
paused || readOnly ? "bg-warning/10 text-warning" : "bg-success/10 text-success",
)}
>
{readOnly ? (
<ShieldAlert className="h-5 w-5" />
) : paused ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5" />
)}
</span>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<p className="text-lead font-semibold text-foreground">
{paused ? "自动化已暂停" : readOnly ? "只读对账中" : "自动化运行中"}
</p>
<StatusBadge tone={paused ? "warning" : "success"}>总开关{paused ? "关" : "开"}</StatusBadge>
{readOnly ? <StatusBadge tone="warning">只读对账</StatusBadge> : null}
</div>
<p className="mt-0.5 text-caption text-muted-foreground">
{paused
? "自动收录、下载、整理已停止,手动操作仍可用。"
: readOnly
? "只核对,不写入。确认无误后关闭。"
: `${a.activeSubscriptions} 个订阅在追踪,自动收录、下载和整理。`}
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-5 gap-y-2">
<div className="flex items-center gap-2 text-caption text-foreground">
<label htmlFor="home-readonly">只读对账</label>
<Switch
id="home-readonly"
checked={readOnly}
disabled={update.isPending}
onCheckedChange={(v) =>
setAutomation({ readOnlyReconcile: v }, v ? "已开启只读对账" : "已关闭只读对账").catch(() => {})
}
aria-label="只读对账模式"
/>
</div>
<p className="min-w-0 flex-1 text-sm text-foreground">
<span className={cn("mr-2 inline-block size-2 rounded-full", warn ? "bg-warning" : "bg-success")} aria-hidden />
{paused ? (
<Button
size="sm"
disabled={update.isPending}
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
>
"自动化已暂停,手动操作仍可用"
) : a.readOnlyReconcile ? (
<>
只读模式:不会自动下载和整理。
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
去设置关闭
</Link>
</>
) : a.activeSubscriptions > 0 ? (
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
) : (
<>
自动化已开启 · 还没有订阅。
<Link to={consolePath("search")} className="underline underline-offset-4">
去搜索添加
</Link>
</>
)}
{problems.map((p) => (
<span key={p.text}>
{" · "}
<Link to={p.to} className="text-warning underline-offset-4 hover:underline">
{p.text}
</Link>
</span>
))}
</p>
{paused ? (
<Button size="sm" disabled={update.isPending} onClick={() => setEnabled(true).catch(() => {})}>
<Play />
恢复自动化
恢复
</Button>
) : (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={update.isPending}>
<Pause />
暂停自动化
暂停
</Button>
}
title="暂停全部自动化?"
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
confirmLabel="暂停自动化"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
onConfirm={() => setEnabled(false)}
/>
)}
</div>
</div>
);
}
// ─── 正在进行:任务流 top N ───
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
const stats = useEngineStats();
const rows = running.data?.items ?? [];
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
function Busy({ w }: { w: Dashboard["currentWork"] }) {
const running = useEngineRunsPage({ status: "running" }, 10, 10_000);
const runs = (running.data?.items ?? []).filter((r) => r.business);
const downloads = w.items.slice(0, 4);
const agents = w.agentRuns.slice(0, 2);
const summary = nonZero([
[w.downloading, "个下载中"],
[w.importing, "个入库中"],
[w.stalled, "个没有进展"],
[w.paused, "个已暂停"],
]);
const empty = !downloads.length && !agents.length && !runs.length;
return (
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
{running.isPending ? (
<Section title="在忙什么" icon={Activity} to="/console/tasks?tab=active">
{running.isPending && !downloads.length ? (
<LoadingState rows={3} />
) : running.isError ? (
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
) : empty ? (
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{rows.slice(0, 5).map((r) => (
{downloads.map((t) => (
<Row key={t.downloadTaskId}>
<Link to={`/console/downloads/${t.downloadTaskId}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{t.name}</span>
{t.status === "stalled" ? <span className="block text-meta text-warning">没有进展</span> : null}
</Link>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
<Download className="mr-1 inline h-3 w-3" aria-hidden />
{Math.round(t.progress * 100)}%
</span>
</Row>
))}
{agents.map((r) => (
<Row key={r.id}>
<Link to={`/console/agent/${r.id}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{r.goal}</span>
</Link>
<Bot className="h-3.5 w-3.5 shrink-0 text-muted-foreground" aria-label="智能体" />
</Row>
))}
{runs.slice(0, Math.max(0, 5 - downloads.length - agents.length)).map((r) => (
<Row key={r.id}>
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{runTitle(r)}</span>
<span className="block truncate text-meta text-muted-foreground">{workflowLabel(r.workflowName)}</span>
</Link>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{formatDuration(spanSec(r))}
</span>
</Row>
))}
</ul>
)}
<p className="mt-auto pt-3 text-meta text-muted-foreground">
排队 {stats.data?.byStatus.pending ?? "—"} · 下载中 {w.downloading} · 无进展 {w.stalled} · 入库中 {w.importing}
</p>
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
</Section>
);
}
// ─── 需要我处理:计数 + 前 3 条 ───
// ─── 要我处理什么:计数 + 前 3 条 ───
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
const list = useDecisions({});
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
return (
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
<Section title="要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
) : (
<>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
@@ -268,9 +278,7 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
<Row key={d.id}>
<Link to={`/console/inbox?kind=${d.kind}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{d.title}</span>
<span className="block truncate text-meta text-muted-foreground">
{decisionKindLabel(d.kind)} · {formatRelative(d.createdAt)}
</span>
<span className="block truncate text-meta text-muted-foreground">{formatRelative(d.createdAt)}</span>
</Link>
</Row>
))}
@@ -281,387 +289,39 @@ function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
);
}
// ─── 媒体概览:作品/季/资产计数 + 收录版本满足率 + 今日产出 ───
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
const c = a.slotCounts;
const total = Object.values(c).reduce((x, y) => x + y, 0);
const rate = a.slotSatisfiedRate === null ? null : Math.round(a.slotSatisfiedRate * 100);
const lib = a.library;
// ─── 最近入库了什么:成功 / 部分成功的整理 ───
function RecentImports({ d }: { d: Dashboard }) {
const rows = d.recentOutput.filter((r) => r.status === "succeeded" || r.status === "partial").slice(0, 6);
const today = d.automation.today.filesImported;
return (
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
<div className="grid grid-cols-3 gap-2">
<StatTile plain label="作品" value={lib.entities} hint={`电影 ${lib.movies} · 剧集 ${lib.series}`} />
<StatTile plain label="季" value={lib.seasons} hint={`${lib.episodes} 集`} />
<StatTile plain label="资产" value={lib.assets} hint="已入库视频" />
</div>
<div className="mt-3">
<div className="flex items-baseline justify-between text-sm">
<span>收录版本满足率</span>
<span className="font-semibold tabular-nums">{rate === null ? "—" : `${rate}%`}</span>
</div>
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div className="h-full rounded-full bg-foreground/60" style={{ width: `${rate ?? 0}%` }} />
</div>
<p className="mt-1.5 text-meta text-muted-foreground">
满足 {c.satisfied} / {total} · 需关注 {c.attention} · 下载中 {c.downloading}
</p>
</div>
<p className="mt-auto pt-3 text-meta text-muted-foreground">
今日 新计划 {a.today.plansCreated} · 提交下载 {a.today.downloadsSubmitted} · 入库文件 {a.today.filesImported} ·
升级 {a.today.upgrades}
</p>
</Section>
);
}
// ─── 计划带:自动任务与巡检分别计数,链到计划页对应节 ───
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 s = planStats(board.data?.items ?? []);
return (
<Section
title="计划"
icon={CalendarCog}
to={planPath()}
extra={
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()} />
) : (
<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="智能体巡检" icon={Bot}>
<span>
启用 <Num>{s.checks.enabled}</Num> / {s.checks.total}
</span>
<span title="近 7 天巡检会话费用合计">
近 7 天 <Num>{formatUsd(s.checks.weekCost)}</Num>
</span>
</PlanCount>
</div>
)}
</Section>
);
}
// ─── 最近完成 / 失败 ───
function RecentRuns() {
const done = useEngineRunsPage({ status: "completed" }, 6);
const failed = useEngineRunsPage({ status: "failed" }, 6);
const rows = [...(done.data?.items ?? []), ...(failed.data?.items ?? [])]
.sort((x, y) => (y.finishedAt ?? y.createdAt).localeCompare(x.finishedAt ?? x.createdAt))
.slice(0, 6);
const loading = done.isPending || failed.isPending;
const err = done.error ?? failed.error;
return (
<Section title="最近完成 / 失败" icon={ListTodo} to="/console/tasks">
{loading ? (
<LoadingState rows={3} />
) : err ? (
<ErrorState
plain
error={err}
onRetry={() => {
void done.refetch();
void failed.refetch();
}}
/>
) : rows.length === 0 ? (
<EmptyState plain icon={ListTodo} title="还没有结束的任务" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{rows.map((r) => {
const s = engineRunStatus(r);
return (
<Row key={r.id}>
<Link to={`/console/tasks?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{runTitle(r)}</span>
<span className="block truncate text-meta text-muted-foreground">
{workflowLabel(r.workflowName)} · {formatRelative(r.finishedAt)}
</span>
</Link>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
);
})}
</ul>
)}
</Section>
);
}
// ─── 来源健康:站点(含账号状态)+ Feed / 下载器 / 媒体服务器,有问题的排前 ───
const ACCOUNT: Record<string, { label: string; tone: StatusTone }> = {
unconfigured: { label: "未配置账号", tone: "neutral" },
unknown: { label: "账号未验证", tone: "neutral" },
active: { label: "账号正常", tone: "success" },
invalid: { label: "账号失效", tone: "destructive" },
expired: { label: "登录过期", tone: "warning" },
challenge: { label: "需人工验证", tone: "warning" },
};
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
const sites = useSitesWithAccounts();
const siteRows = (sites.data?.items ?? s.sites).map((x) => {
const acc = "accountStatus" in x && x.accountStatus ? ACCOUNT[x.accountStatus] : undefined;
return {
id: x.id,
kind: "站点",
name: x.name,
enabled: x.enabled,
health: "healthStatus" in x ? x.healthStatus : x.health,
account: acc,
to: `/console/sites/${x.id}`,
};
});
const rows = [
...siteRows,
...s.feeds.map((x) => ({
id: x.id,
kind: "RSS",
name: x.name,
enabled: x.enabled,
health: x.health,
account: undefined,
to: consolePath("sources"),
})),
...s.downloaders.map((x) => ({
id: x.id,
kind: "下载器",
name: x.name,
enabled: x.enabled,
health: x.health,
account: undefined,
to: settingsPath("downloaders"),
})),
];
const bad = (r: (typeof rows)[number]) =>
(r.enabled && healthLabel(r.health).tone !== "success" ? 2 : 0) +
(r.account && r.account.tone !== "success" ? 1 : 0);
rows.sort((x, y) => bad(y) - bad(x));
const accountIssues = siteRows.filter(
(r) => r.account && (r.account.tone === "destructive" || r.account.tone === "warning"),
).length;
return (
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
<Section title="最近入库" icon={LibraryIcon} to={consolePath("organize")}>
{rows.length === 0 ? (
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 RSS 后显示。" className="py-6" />
<EmptyState plain icon={LibraryIcon} title="最近没有新入库的作品" className="py-6" />
) : (
<>
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
<ul className="divide-y divide-border/60">
{rows.slice(0, 7).map((r) => {
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
return (
<Row key={`${r.kind}-${r.id}`}>
<Link to={r.to} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
{r.name}
</span>
{r.account ? (
<span
className={cn(
"block truncate text-meta",
r.account.tone === "destructive"
? "text-destructive"
: r.account.tone === "warning"
? "text-warning"
: "text-muted-foreground",
)}
>
{r.account.label}
</span>
) : null}
</Link>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
</Row>
);
})}
</ul>
</>
)}
</Section>
);
}
// ─── 运行状态摘要(/system/health) ───
const LEVEL: Record<string, { label: string; tone: StatusTone }> = {
ok: { label: "正常", tone: "success" },
warn: { label: "需要关注", tone: "warning" },
critical: { label: "需要处理", tone: "destructive" },
unknown: { label: "未知", tone: "neutral" },
};
function StatusSummary() {
const h = useSystemHealth(30_000);
const d = h.data;
const stale = d?.processes.filter((p) => p.stale) ?? [];
const alive = (d?.processes.length ?? 0) - stale.length;
const mountsBad = d?.mounts.filter((m) => m.status !== "online").length ?? 0;
const lv = LEVEL[d?.level ?? "unknown"] ?? LEVEL.unknown;
return (
<Section
title="运行状态"
icon={Gauge}
to={consolePath("status")}
className="md:col-span-2 xl:col-span-3"
extra={d ? <StatusBadge tone={lv.tone}>{lv.label}</StatusBadge> : null}
>
{h.isPending ? (
<LoadingState rows={1} />
) : h.isError ? (
<ErrorState plain error={h.error} onRetry={() => h.refetch()} />
) : d ? (
<div className="flex flex-wrap gap-x-6 gap-y-1.5 text-sm">
<span>
进程 <span className="font-medium tabular-nums">{alive}</span> 在线
{stale.length ? (
<span className="text-warning">
{" "}
· {stale.length} 个无心跳({stale.map((p) => `${p.processKind} ${p.pid}`).join("、")})
</span>
) : null}
</span>
<span>
待执行 <span className="font-medium tabular-nums">{d.engine.pending}</span>
{d.engine.oldestPendingAgeSec ? ` · 最久 ${formatDuration(d.engine.oldestPendingAgeSec)}` : ""}
</span>
<span>
24 小时失败率{" "}
<span className="font-medium tabular-nums">
{d.engine.failureRate24h === null ? "—" : `${(d.engine.failureRate24h * 100).toFixed(1)}%`}
</span>
</span>
<span>
数据库{" "}
<span className="font-medium tabular-nums">{d.db.latencyMs === null ? "—" : `${d.db.latencyMs} 毫秒`}</span>
</span>
{mountsBad ? <span className="text-destructive">{mountsBad} 个挂载根不可用</span> : null}
</div>
) : null}
</Section>
);
}
function CacheSection({ c }: { c: Dashboard["cache"] }) {
const volumeLabel = useVolumeLabel();
return (
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
{c.volumes.length === 0 ? (
<EmptyState
plain
icon={HardDrive}
title="还没有下载器"
description="添加下载器后显示缓存水位。"
className="py-6"
/>
) : (
<ul className="space-y-4">
{c.volumes.map((v) => {
const pct = v.usedPercent;
const over = pct !== null && pct >= v.hardThresholdPercent;
const near = pct !== null && pct >= v.resumePercent;
return (
<li key={v.volumeKey} className="min-w-0">
<div className="flex items-center justify-between gap-2 text-sm">
<span className="truncate font-medium">{volumeLabel(v)}</span>
<StatusBadge tone={pct === null ? "neutral" : over ? "destructive" : near ? "warning" : "success"}>
{pct === null ? "用量未知" : `${pct.toFixed(0)}%${over ? " 已达阈值" : ""}`}
</StatusBadge>
</div>
<div className="relative mt-2 h-1.5 overflow-hidden rounded-full bg-foreground/[0.08]" aria-hidden>
<div
className={cn(
"h-full rounded-full",
over ? "bg-destructive" : near ? "bg-warning" : "bg-foreground/60",
)}
style={{ width: `${pct ?? 0}%` }}
/>
<span
aria-hidden
className="absolute inset-y-0 w-px bg-destructive"
style={{ left: `${v.hardThresholdPercent}%` }}
/>
</div>
<p className="mt-1.5 text-meta text-muted-foreground">
阈值 {v.hardThresholdPercent}% / 恢复 {v.resumePercent}% · 在途 {formatBytes(v.pendingBytes)} · 可清理{" "}
{formatBytes(v.cleanableBytes)} · 保留期锁定 {formatBytes(v.retentionLockedBytes)}
{v.pausedByThreshold ? ` · 因阈值暂停 ${v.pausedByThreshold} 个` : ""}
</p>
{v.notes.map((n) => (
<p key={n} className="text-meta text-muted-foreground">
{n}
</p>
))}
</li>
);
})}
</ul>
)}
{rows.map((r) => (
<Row key={r.recordId}>
<Link
to={settingsPath("automation", "retention")}
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
to={r.mediaEntityId ? `/console/library/${r.mediaEntityId}` : `/console/organize/${r.recordId}`}
className="min-w-0 flex-1 hover:underline"
>
调整阈值与保留天数
<span className="block truncate">{r.title ?? r.targetPath?.split("/").pop() ?? "未关联作品"}</span>
{r.status === "partial" ? (
<span className="block text-meta text-warning">
{r.counts.succeeded}/{r.counts.total} 个文件入库
</span>
) : null}
</Link>
<span className="shrink-0 text-meta text-muted-foreground">{formatRelative(r.finishedAt)}</span>
</Row>
))}
</ul>
)}
{today ? <p className="mt-auto pt-3 text-meta text-muted-foreground">今天入库 {today} 个文件</p> : null}
</Section>
);
}
function LiveIndicator() {
const s = useSseStatus();
const tone = s === "open" ? "success" : s === "reconnecting" ? "warning" : "neutral";
const label = s === "open" ? "实时" : s === "reconnecting" ? "重连中" : "连接中";
return <StatusBadge tone={tone}>{label}</StatusBadge>;
}
export default function Home() {
const q = useLiveDashboard();
useLiveTasks();
@@ -669,21 +329,15 @@ export default function Home() {
<>
<PageHeader
title="首页"
description="正在进行的任务、需要处理的事项、计划和最近结果。"
actions={
<div className="flex items-center gap-2">
<LiveIndicator />
{q.data ? (
<span className="hidden text-meta text-muted-foreground sm:inline">
更新于 {formatRelative(q.data.generatedAt)}
</span>
) : null}
</div>
q.data ? (
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
) : null
}
/>
{q.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c", "d", "e", "f"].map((k) => (
{["a", "b", "c"].map((k) => (
<div key={k} className="pg-slab rounded-xl p-5">
<LoadingState rows={3} />
</div>
@@ -693,16 +347,11 @@ export default function Home() {
<ErrorState title="首页加载失败" error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<AutomationBanner a={q.data.automation} />
<StatusLine d={q.data} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<InProgress w={q.data.currentWork} />
<Busy w={q.data.currentWork} />
<NeedsMe n={q.data.needsAttention} />
<MediaOverview a={q.data.automation} />
<PlanStrip />
<RecentRuns />
<CacheSection c={q.data.cache} />
<SourcesSection s={q.data.sources} />
<StatusSummary />
<RecentImports d={q.data} />
</div>
</>
)}
@@ -18,7 +18,6 @@ export default function SchedulesPage() {
<>
<PageHeader
title="计划"
description="定时任务的开关、间隔和下次运行。每次运行的结果在任务页。"
actions={
<Button
variant="outline"
+1 -2
View File
@@ -20,7 +20,7 @@ export const clientLoader = legacyRedirectLoader;
type Tab = StreamTab;
const TABS: Tab[] = ["all", "active", "failed"];
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow"];
const FILTER_KEYS: (keyof StreamFilters)[] = ["status", "kind", "ref", "source", "since", "workflow", "system"];
function Count({ n }: { n: number | undefined }) {
if (!n) return null;
@@ -99,7 +99,6 @@ export default function TasksPage() {
<>
<PageHeader
title="任务"
description="每次运行都是一条任务,不论定时、事件、手动还是智能体触发。"
actions={
<Button variant="outline" size="sm" onClick={() => qc.invalidateQueries({ queryKey: qk.all("engine") })}>
<RotateCw />
+3 -3
View File
@@ -803,14 +803,14 @@ export const SETTINGS_SECTIONS: readonly SettingsSectionDef[] = [
{
id: "automation",
label: "自动化",
description: "总开关、缓存水位、每日预算、保留天数",
description: "总开关、下载盘、每日预算、保留天数",
legacyTabs: [{ tab: "automation" }, { tab: "storage", anchor: "cache" }],
anchors: [
{ id: "switches", label: "开关" },
{ id: "cache", label: "缓存水位" },
{ id: "cache", label: "下载盘" },
{ id: "budget", label: "每日预算" },
{ id: "retention", label: "保留" },
{ id: "storage", label: "存储与保留" },
{ id: "advanced", label: "高级" },
],
fields: [
"automation.enabled",