Files
UHD_Media_Tool/apps/web/app/routes/console/home.tsx
T

346 lines
13 KiB
TypeScript

import { useQueryClient } from "@tanstack/react-query";
import type { Dashboard } from "@umt/contracts";
import {
Activity,
AlertTriangle,
ArrowRight,
Bot,
CircleCheck,
Download,
Library as LibraryIcon,
type LucideIcon,
Pause,
Play,
} from "lucide-react";
import type { ReactNode } from "react";
import { useRef } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useAttention } from "~/api/attention.ts";
import { useDashboard, useSitesWithAccounts } from "~/api/dashboard.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 { AttentionList, attentionRows } from "~/components/dashboard/attention-summary.tsx";
import { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/live.ts";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatRelative } from "~/lib/labels.ts";
import { qk } from "~/lib/query-keys.ts";
import { useSse } from "~/lib/sse.ts";
import { cn } from "~/lib/utils.ts";
// 首页概览:自动化、存储与下载器状态,以及当前工作、要我处理的汇总计数(整理需要处理 / 等你批准 / 系统健康)和最近整理。
/** 首页关心的事件:任意一个到达就(节流)刷新聚合 */
const DASHBOARD_EVENTS = [
"task.created",
"task.status_changed",
"download.submitted",
"download.status_changed",
"download.threshold",
"organization.changed",
"agent.run_status",
"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" aria-hidden />
<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>;
}
/** 非零项拼一句:[[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 health = useSystemHealth(60_000);
const sites = useSitesWithAccounts();
const setEnabled = async (enabled: boolean) => {
try {
await update.mutateAsync({ automation: { enabled } });
toast.success(enabled ? "自动化已恢复" : "自动化已暂停");
} catch (err) {
toast.error(errorMessage(err, "切换失败"));
throw err;
}
};
// 只列不正常的:服务异常、下载盘到暂停线、站点账号失效、下载器离线
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-3 rounded-xl px-4 py-3 sm:flex-row sm:items-center sm:px-5",
warn && "border border-warning/40",
)}
>
<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 ? (
"自动化已暂停,手动操作仍可用"
) : a.readOnlyReconcile ? (
<>
只读模式:不会自动下载和整理。
<Link to={settingsPath("system", "readonly")} 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={() => setEnabled(false)}
/>
)}
</div>
);
}
// ─── 在忙什么:下载中的任务 + 进行中的智能体会话 + 有业务对象的运行 ───
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/logs?type=task&level=running">
{running.isPending && !downloads.length ? (
<LoadingState rows={3} />
) : empty ? (
<EmptyState plain icon={CircleCheck} title="现在没有在跑的任务" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{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/logs?run=${encodeURIComponent(r.id)}`} className="min-w-0 flex-1 hover:underline">
<span className="block truncate">{runTitle(r)}</span>
</Link>
</Row>
))}
</ul>
)}
{summary ? <p className="mt-auto pt-3 text-meta text-muted-foreground">{summary}</p> : null}
</Section>
);
}
// ─── 要我处理什么:汇总计数(整理需要处理、等你批准、系统健康),点进去到对应页面 ───
function NeedsMe() {
const q = useAttention();
const rows = attentionRows(q.data);
return (
<Section title="要我处理" icon={AlertTriangle}>
{q.isPending ? (
<LoadingState rows={2} />
) : rows.length === 0 ? (
<EmptyState plain icon={CircleCheck} title="没有要你处理的事" className="py-6" />
) : (
<AttentionList rows={rows} />
)}
</Section>
);
}
// ─── 最近整理了什么:成功 / 部分成功的整理 ───
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("organize")}>
{rows.length === 0 ? (
<EmptyState plain icon={LibraryIcon} title="最近没有新整理的作品" className="py-6" />
) : (
<ul className="divide-y divide-border/60">
{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>
) : 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>
);
}
export default function Home() {
const q = useLiveDashboard();
useLiveTasks();
return (
<>
<PageHeader
title="首页"
actions={
q.data ? (
<span className="text-meta text-muted-foreground">更新于 {formatRelative(q.data.generatedAt)}</span>
) : null
}
/>
{q.data ? <StatusLine d={q.data} /> : null}
<HomeRuntime downloaders={q.data?.sources.downloaders} />
{q.isPending ? (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{["a", "b", "c"].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()} />
) : (
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<Busy w={q.data.currentWork} />
<NeedsMe />
<RecentImports d={q.data} />
</div>
)}
</>
);
}