Files
UHD_Media_Tool/apps/web/app/routes/console/home.tsx
T
rootandClaude Opus 5.5 0dec0854df feat(web): W3-1 login, session shell, home dashboard, settings, engine panel and audit pages
- /login: setup/login/TOTP states driven by server error codes, fixed-height error slot
- console layout: GET /auth/session gate, redirect to /login, account popover (change password, logout)
- home: six dashboard sections with SSE-driven refresh, automation switch and read-only reconcile toggle
- settings tabs: downloaders, media servers, library roots, path mappings, site credentials,
  API keys (one-time plaintext), Pi upstream, cache/organize/retention/budget, automation
- engine: runs list (status/name filters, cursor paging, stats tiles), run detail (step timeline,
  json payloads, signals, cancel)
- audit: filterable list with detail sheet
- vite: allow repo root in server.fs so deno-installed deps under node_modules/.deno load in dev

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-27 20:40:41 +08:00

516 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useQueryClient } from "@tanstack/react-query";
import type { Dashboard } from "@umt/contracts";
import {
Activity,
AlertTriangle,
ArrowRight,
CircleCheck,
HardDrive,
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 { useUpdateSettings } from "~/api/settings.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 { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.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 { qk } from "~/lib/query-keys.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
"task.created",
"task.status_changed",
"download.submitted",
"download.status_changed",
"download.threshold",
"organization.changed",
"agent.run_status",
"feed.fetched",
"feed.health_changed",
"decision.created",
"decision.updated",
"settings.changed",
];
function useLiveDashboard() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse(DASHBOARD_EVENTS, () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
qc.invalidateQueries({ queryKey: qk.dashboard });
}, 1500);
});
return useDashboard();
}
function Section({
title,
icon: Icon,
to,
children,
className,
}: {
title: string;
icon: LucideIcon;
to?: string;
children: ReactNode;
className?: string;
}) {
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>
{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}
</div>
{children}
</section>
);
}
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"] }) {
const update = useUpdateSettings();
const setAutomation = async (patch: { enabled?: boolean; readOnlyReconcile?: boolean }, ok: string) => {
try {
await update.mutateAsync({ automation: patch });
toast.success(ok);
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
};
const paused = !a.enabled;
const readOnly = a.readOnlyReconcile;
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",
)}
>
<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>
{paused ? (
<Button
size="sm"
disabled={update.isPending}
onClick={() => setAutomation({ enabled: true }, "自动化已恢复").catch(() => {})}
>
<Play />
恢复自动化
</Button>
) : (
<ConfirmDialog
trigger={
<Button variant="outline" size="sm" disabled={update.isPending}>
<Pause />
暂停自动化
</Button>
}
title="暂停全部自动化?"
description="自动收录、下载、整理会停下,已提交的下载任务不受影响。随时可以恢复。"
confirmLabel="暂停"
onConfirm={() => setAutomation({ enabled: false }, "自动化已暂停")}
/>
)}
</div>
</div>
);
}
function Overview({ a }: { a: Dashboard["automation"] }) {
const t = a.today;
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}
</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;
return (
<Section title="当前工作" icon={Activity} to={consolePath("tasks")}>
<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" />
) : (
<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("tasks", it.downloadTaskId)} className="min-w-0 flex-1 truncate hover:underline">
{it.name}
</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>
);
})}
{w.agentRuns.slice(0, 3).map((r) => {
const s = agentRunLabel(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>
<StatusBadge tone={s.tone}>{s.label}</StatusBadge>
</Row>
);
})}
</ul>
)}
</Section>
);
}
function NeedsAttention({ n }: { n: Dashboard["needsAttention"] }) {
return (
<Section title="需要我处理" icon={AlertTriangle} to={consolePath("inbox")}>
{n.total === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有待你决定的事" 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>
<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>
))}
</ul>
</>
)}
</Section>
);
}
function CacheSection({ c }: { c: Dashboard["cache"] }) {
return (
<Section title="缓存与保留" icon={HardDrive} to={consolePath("settings")}>
{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">{v.volumeKey}</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>
)}
</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>
)}
</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();
return (
<>
<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.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c", "d", "e", "f"].map((k) => (
<div key={k} className="pg-slab rounded-xl p-5">
<LoadingState rows={3} />
</div>
))}
</div>
) : q.isError ? (
<ErrorState title="首页数据加载失败" error={q.error} onRetry={() => q.refetch()} />
) : (
<>
<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} />
<CacheSection c={q.data.cache} />
<SourcesSection s={q.data.sources} />
<RecentOutput items={q.data.recentOutput} />
</div>
</>
)}
</>
);
}