feat(web): status page and home regrouped around tasks, inbox and plan (FE-1)

Status page reads /system/health and history: level banner, process table,
event-loop lag and SSE charts (monochrome recharts), database, engine,
schedules, disks with thresholds and mount roots. Home now shows in-progress
tasks, items needing attention, media overview, a plan strip with quick
toggles, recent finished/failed tasks, cache and retention, source health
with site account status, and a run-status summary.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:10:42 +08:00
1 parent d8b9bd9662
commit 4bb2583f6c
5 files changed
+1012 -182

No files matched your search

+384 -181
View File
@@ -4,47 +4,50 @@ import {
Activity,
AlertTriangle,
ArrowRight,
CalendarClock,
CircleCheck,
Gauge,
HardDrive,
Library as LibraryIcon,
ListTodo,
type LucideIcon,
Pause,
Play,
Radio,
ShieldAlert,
Sparkles,
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useDashboard } from "~/api/dashboard.ts";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.ts";
import { useEngineRunsPage, useEngineStats } from "~/api/engine.ts";
import { useUpdateSchedule } from "~/api/schedules.ts";
import { useUpdateSettings } from "~/api/settings.ts";
import { useSystemHealth } from "~/api/system.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
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 } from "~/components/console/status-badge.tsx";
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
import { useVolumeLabel } from "~/components/downloads/cache-volumes.tsx";
import { countdown, RUN_STATUS } from "~/components/schedules/labels.ts";
import { useLiveSchedules, useNow } from "~/components/schedules/schedule-board.tsx";
import { runStatus, runTitle, workflowLabel } 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 {
agentRunLabel,
decisionKindLabel,
downloadLabel,
formatRelative,
healthLabel,
organizationActionLabel,
organizationLabel,
} from "~/lib/labels.ts";
import { decisionKindLabel, formatDuration, formatRelative, healthLabel, spanSec } 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";
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
"schedule.changed",
"task.created",
"task.status_changed",
"download.submitted",
@@ -78,18 +81,22 @@ 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" />
<h2 className="text-body font-semibold text-foreground">{title}</h2>
{extra}
{to ? (
<Link
to={to}
@@ -204,68 +211,226 @@ function AutomationBanner({ a }: { a: Dashboard["automation"] }) {
);
}
function Overview({ a }: { a: Dashboard["automation"] }) {
const t = a.today;
// ─── 正在进行:任务流 top N ───
function InProgress({ w }: { w: Dashboard["currentWork"] }) {
const running = useEngineRunsPage({ status: "running" }, 6, 10_000);
const stats = useEngineStats();
const rows = running.data?.items ?? [];
return (
<Section title="自动化概览" icon={Sparkles} to={consolePath("subscriptions")}>
<div className="grid grid-cols-2 gap-2">
<StatTile plain label="今日新计划" value={t.plansCreated} />
<StatTile plain label="今日提交下载" value={t.downloadsSubmitted} />
<StatTile plain label="今日入库文件" value={t.filesImported} />
<StatTile plain label="今日升级" value={t.upgrades} />
</div>
<p className="mt-3 text-meta text-muted-foreground">
整理成功 {t.recordsSucceeded} · 失败 {t.recordsFailed} · 满足的版本目标 {a.slotCounts.satisfied} · 需关注{" "}
{a.slotCounts.attention}
<Section title="正在进行" icon={Activity} to="/console/tasks?tab=active">
{running.isPending ? (
<LoadingState rows={3} />
) : running.isError ? (
<ErrorState plain error={running.error} onRetry={() => running.refetch()} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有进行中的任务" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{rows.slice(0, 5).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>
</Section>
);
}
function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
const counts = [
{ label: "下载中", v: w.downloading },
{ label: "已暂停", v: w.paused },
{ label: "无进展", v: w.stalled },
{ label: "入库中", v: w.importing },
];
const idle = counts.every((c) => c.v === 0) && w.items.length === 0 && w.agentRuns.length === 0;
// ─── 需要我处理:计数 + 前 3 条 ───
function NeedsMe({ n }: { n: Dashboard["needsAttention"] }) {
const list = useDecisions({});
const top = (list.data?.pages[0]?.items ?? []).slice(0, 3);
return (
<Section title="当前工作" icon={Activity} to={consolePath("downloads")}>
<div className="mb-2 flex flex-wrap gap-x-4 gap-y-1 text-meta text-muted-foreground">
{counts.map((c) => (
<span key={c.label}>
{c.label} <span className="font-medium text-foreground tabular-nums">{c.v}</span>
</span>
))}
</div>
{idle ? (
<EmptyState plain icon={CircleCheck} title="没有进行中的工作" className="py-6" />
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{w.items.slice(0, 5).map((it) => {
const s = downloadLabel(it.status);
return (
<Row key={it.downloadTaskId}>
<Link
to={consolePath("downloads", it.downloadTaskId)}
className="min-w-0 flex-1 truncate hover:underline"
>
{it.name}
<>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
{n.total}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">
项 · {n.byKind.map((k) => `${decisionKindLabel(k.kind)} ${k.count}`).join(" · ")}
</span>
</p>
<ul className="divide-y divide-border/60">
{top.map((d) => (
<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>
</Link>
<span className="shrink-0 text-meta text-muted-foreground tabular-nums">
{Math.round(it.progress * 100)}%
</span>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
))}
</ul>
</>
)}
</Section>
);
}
// ─── 媒体概览:版本目标满足率 + 今日产出(作品/季/资产计数待 /dashboard 提供) ───
function MediaOverview({ a }: { a: Dashboard["automation"] }) {
const c = a.slotCounts;
const total = Object.values(c).reduce((x, y) => x + y, 0);
const rate = total ? Math.round((c.satisfied / total) * 100) : null;
const lib = (a as { library?: { entities?: number; seasons?: number; assets?: number } }).library;
const num = (v: number | undefined) => (v === undefined ? "—" : v);
return (
<Section title="媒体概览" icon={LibraryIcon} to={consolePath("library")}>
<div className="grid grid-cols-3 gap-2">
<StatTile plain label="作品" value={num(lib?.entities)} />
<StatTile plain label="季" value={num(lib?.seasons)} />
<StatTile plain label="资产" value={num(lib?.assets)} />
</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>
);
}
// ─── 计划带:下一次运行的 5 项 + 计数 + 快速开关 ───
function PlanStrip() {
const board = useLiveSchedules();
const now = useNow(1000);
const update = useUpdateSchedule();
const items = board.data?.items ?? [];
const next = items
.filter((j) => j.available && j.enabled && j.nextRunAt)
.sort((x, y) => (x.nextRunAt ?? "").localeCompare(y.nextRunAt ?? ""))
.slice(0, 5);
const s = board.data?.stats;
return (
<Section
title="计划"
icon={CalendarClock}
to="/console/tasks?tab=plan"
className="md:col-span-2 xl:col-span-3"
extra={
s ? (
<span className="text-meta text-muted-foreground">
启用 <span className="font-medium text-foreground tabular-nums">{s.enabled}</span> / {s.total}
{s.failed ? (
<>
{" · "}
<span className="font-medium text-destructive tabular-nums">上次失败 {s.failed}</span>
</>
) : null}
{board.data && !board.data.schedulerOnline ? <span className="text-warning"> · 调度器未在线</span> : null}
</span>
) : null
}
>
{board.isPending ? (
<LoadingState rows={2} />
) : board.isError ? (
<ErrorState plain error={board.error} onRetry={() => board.refetch()} />
) : next.length === 0 ? (
<EmptyState plain icon={CalendarClock} title="没有待运行的定时任务" className="py-4" />
) : (
<ul className="grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
{next.map((j) => {
const last = j.lastStatus ? RUN_STATUS[j.lastStatus] : null;
return (
<li key={j.id} className="flex min-w-0 flex-col gap-1.5 rounded-xl border border-border/70 px-3 py-2.5">
<div className="flex items-center gap-2">
<Link
to={`/console/tasks?tab=plan&job=${encodeURIComponent(j.id)}`}
className="min-w-0 flex-1 truncate text-sm font-medium hover:underline"
title={j.name}
>
{j.name}
</Link>
<Switch
checked={j.enabled}
disabled={update.isPending}
aria-label={`${j.enabled ? "停用" : "启用"}${j.name}`}
onCheckedChange={(v) =>
update.mutate(
{ id: j.id, body: { enabled: v } },
{
onSuccess: () => toast.success(v ? `已启用${j.name}` : `已停用${j.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
<p className="flex items-center justify-between gap-2 text-meta text-muted-foreground">
<span className="tabular-nums">
{j.runningSince ? "运行中" : j.nextRunAt ? countdown(j.nextRunAt, now) : "—"}
</span>
{last ? <StatusBadge tone={last.tone}>{last.label}</StatusBadge> : null}
</p>
</li>
);
})}
{w.agentRuns.slice(0, 3).map((r) => {
const s = agentRunLabel(r.status);
</ul>
)}
</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 = runStatus(r.status);
return (
<Row key={r.id}>
<Link to={consolePath("agent", r.id)} className="min-w-0 flex-1 truncate hover:underline">
Agent:{r.goal}
<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>
@@ -277,24 +442,94 @@ function CurrentWork({ w }: { w: Dashboard["currentWork"] }) {
);
}
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
// ─── 来源健康:站点(含账号状态)+ 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: "Feed",
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={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待处理事项" className="py-6" />
<Section title="来源健康" icon={Radio} to={consolePath("sites")}>
{rows.length === 0 ? (
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
) : (
<>
<p className="mb-2 text-title-sm font-semibold text-warning tabular-nums">
{n.total}
<span className="ml-1.5 text-caption font-normal text-muted-foreground">项待处理</span>
</p>
{accountIssues ? <p className="mb-1 text-meta text-warning">{accountIssues} 个站点的账号需要处理</p> : null}
<ul className="divide-y divide-border/60">
{n.byKind.map((k) => (
<Row key={k.kind}>
<span className="truncate">{decisionKindLabel(k.kind)}</span>
<span className="text-meta text-muted-foreground tabular-nums">{k.count}</span>
</Row>
))}
{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>
</>
)}
@@ -302,10 +537,69 @@ function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
);
}
// ─── 运行状态摘要(/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")}>
<Section title="缓存与保留" icon={HardDrive} to={consolePath("downloads")}>
{c.volumes.length === 0 ? (
<EmptyState
plain
@@ -357,106 +651,12 @@ function CacheSection({ c }: { c: Dashboard["cache"] }) {
})}
</ul>
)}
</Section>
);
}
function SourcesSection({ s }: { s: Dashboard["sources"] }) {
const rows = [
...s.sites.map((x) => ({
id: x.id,
kind: "站点",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSnapshotAt,
err: null as string | null,
})),
...s.feeds.map((x) => ({
id: x.id,
kind: "Feed",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSuccessAt,
err: x.lastError,
})),
...s.downloaders.map((x) => ({
id: x.id,
kind: "下载器",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSeenAt,
err: null,
})),
...s.mediaServers.map((x) => ({
id: x.id,
kind: "媒体服务器",
name: x.name,
enabled: x.enabled,
health: x.health,
at: x.lastSeenAt,
err: null,
})),
];
return (
<Section title="来源健康" icon={Radio} to={consolePath("sources")}>
{rows.length === 0 ? (
<EmptyState plain icon={Radio} title="还没有来源" description="登记站点或新建 Feed 后显示。" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{rows.slice(0, 8).map((r) => {
const h = r.enabled ? healthLabel(r.health) : healthLabel("disabled");
return (
<Row key={`${r.kind}-${r.id}`}>
<div className="min-w-0 flex-1">
<p className="truncate">
<span className="mr-1.5 text-meta text-muted-foreground">{r.kind}</span>
{r.name}
</p>
{r.err ? <p className="truncate text-meta text-destructive">{r.err}</p> : null}
</div>
<span className="hidden shrink-0 text-meta text-muted-foreground sm:inline">
{formatRelative(r.at)}
</span>
<StatusBadge tone={h.tone}>{h.label}</StatusBadge>
</Row>
);
})}
</ul>
)}
</Section>
);
}
function RecentOutput({ items }: { items: Dashboard["recentOutput"] }) {
return (
<Section title="最近产出" icon={ListTodo} to={consolePath("organize")}>
{items.length === 0 ? (
<EmptyState plain icon={ListTodo} title="还没有整理记录" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{items.slice(0, 6).map((r) => {
const s = organizationLabel(r.status);
return (
<li key={r.recordId} className="min-w-0 py-2">
<div className="flex items-center justify-between gap-3 text-sm">
<Link to={consolePath("organize", r.recordId)} className="min-w-0 truncate hover:underline">
{r.title ?? "未关联作品"}
</Link>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</div>
<p className="truncate text-meta text-muted-foreground">
{organizationActionLabel(r.action)} · {r.counts.succeeded}/{r.counts.total} 个文件 ·{" "}
{formatRelative(r.finishedAt)}
{r.targetPath ? ` · ${r.targetPath}` : ""}
</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>
);
}
@@ -470,11 +670,12 @@ function LiveIndicator() {
export default function Home() {
const q = useLiveDashboard();
useLiveTasks();
return (
<>
<PageHeader
title="首页"
description="目标进度、今日产出、缺口和待处理事项。"
description="正在进行的任务、需要处理的事项、计划和最近结果。"
actions={
<div className="flex items-center gap-2">
<LiveIndicator />
@@ -500,12 +701,14 @@ export default function Home() {
<>
<AutomationBanner a={q.data.automation} />
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Overview a={q.data.automation} />
<CurrentWork w={q.data.currentWork} />
<NeedsAttention n={q.data.needsAttention} />
<InProgress 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} />
<RecentOutput items={q.data.recentOutput} />
<StatusSummary />
</div>
</>
)}