Files
UHD_Media_Tool/apps/web/app/routes/console/home.tsx
T
rootandClaude Opus 5.5 ceb66f8c77 feat: v0.3.8–v0.3.14 工作区汇总(依赖升级、整理来源、手动入库、原地入库等)
生产 v0.3.8~v0.3.13 均由 8013196 加工作区修改构建部署,此前未提交;本提交补齐源码,
并含未部署的 v0.3.14 原地入库。中间各版本源码状态未保存,无法逐版拆分,详见
docs/plan/17-full-upgrade.md 与 docs/plan/18-organize-release.md。

- v0.3.8 全面升级:Deno workspace 依赖、Router 8 / Vite 8、camofox 1.18.0 / Node 24、
  PostgreSQL 18.6、备份客户端 18.6、演示 qB 5.2.4
- v0.3.9 媒体整理:分类规则、整理来源与目录快照(0059)、文件管理整理入口
- v0.3.10 手动下载与入库:独立手动下载、详情页资源下载、TMDB / Local 元数据兜底
  (metadata.resolve_organize)、正常多版本 / 一次性洗版(0060)
- v0.3.11 搜索详情与桌面黑边修复
- v0.3.12 列表分页、搜索历史、首页概况
- v0.3.13 统一整理弹窗与队列
- v0.3.14 原地入库(0061,未部署)

同批审计修复:
- 刮削入队:确认原记录存在后才删重放产生的新记录;写回 run 关联失败时保持 queued,
  由处理器补关联(对齐 MP 先登记、只在入队失败时记失败)
- 识别升级链只在结果更确定时采用,不再用 no_match 顶替原有歧义候选
- bluray_dir 资产以原盘目录本身为刮削根(MP 同做法)
- metadata.resolve_organize 登记到入库排障 Skill 与能力地图
- db/src/types.ts 按迁移重新生成;contracts 整理动作枚举顺序与数据库对齐
- 测试:原地入库用例改为直接查记录表;domain 测试模板补 version
- 前端:通知铃铛弹层与用户菜单统一样式;站点卡片共用卡头、底栏贴底、分页间距

依赖升级证据只提交摘要文件,registry-snapshot.json 移至 .local/(SHA-256 记于文档)。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 15:47:17 +08:00

360 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 { useDashboard, useSitesWithAccounts } from "~/api/dashboard.ts";
import { useDecisions } from "~/api/decisions.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 { HomeRuntime } from "~/components/dashboard/home-runtime.tsx";
import { runTitle } from "~/components/tasks/labels.ts";
import { useLiveTasks } from "~/components/tasks/task-stream.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import { decisionKindLabel, 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("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={() => 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/tasks?tab=active">
{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/tasks?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>
);
}
// ─── 要我处理什么:计数 + 前 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")}>
{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">
项 · {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">{formatRelative(d.createdAt)}</span>
</Link>
</Row>
))}
</ul>
</>
)}
</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 n={q.data.needsAttention} />
<RecentImports d={q.data} />
</div>
)}
</>
);
}