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
@@ -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(() => {})}
|
||||
>
|
||||
<Play />
|
||||
恢复自动化
|
||||
</Button>
|
||||
"自动化已暂停,手动操作仍可用"
|
||||
) : a.readOnlyReconcile ? (
|
||||
<>
|
||||
只读模式:不会自动下载和整理。
|
||||
<Link to={settingsPath("automation", "advanced")} className="underline underline-offset-4">
|
||||
去设置关闭
|
||||
</Link>
|
||||
</>
|
||||
) : a.activeSubscriptions > 0 ? (
|
||||
`自动化已开启 · ${a.activeSubscriptions} 个订阅`
|
||||
) : (
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button variant="outline" size="sm" disabled={update.isPending}>
|
||||
<Pause />
|
||||
暂停自动化
|
||||
</Button>
|
||||
}
|
||||
title="暂停全部自动化?"
|
||||
description="自动收录、下载、整理会停止,已提交的下载任务不受影响。可随时恢复。"
|
||||
confirmLabel="暂停自动化"
|
||||
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
|
||||
/>
|
||||
<>
|
||||
自动化已开启 · 还没有订阅。
|
||||
<Link to={consolePath("search")} className="underline underline-offset-4">
|
||||
去搜索添加
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{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={() => setEnabled(false)}
|
||||
/>
|
||||
)}
|
||||
</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" />
|
||||
<Section title="最近入库" icon={LibraryIcon} to={consolePath("organize")}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState plain icon={LibraryIcon} 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)}
|
||||
{rows.map((r) => (
|
||||
<Row key={r.recordId}>
|
||||
<Link
|
||||
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>
|
||||
</Link>
|
||||
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
|
||||
</Row>
|
||||
);
|
||||
})}
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 来源健康:站点(含账号状态)+ 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")}>
|
||||
{rows.length === 0 ? (
|
||||
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 RSS 后显示。" 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>
|
||||
)}
|
||||
<Link
|
||||
to={settingsPath("automation", "retention")}
|
||||
className="mt-3 text-meta text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
|
||||
>
|
||||
调整阈值与保留天数
|
||||
</Link>
|
||||
</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"
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in new issue
Block a user