Files
UHD_Media_Tool/apps/web/app/routes/console/library.tsx
T
2026-09-28 02:09:39 +08:00

169 lines
6.2 KiB
TypeScript

import type { LibraryItem, LibraryListQuery, MediaKind, SlotStatus } from "@umt/contracts";
import { Download, Library as LibraryIcon, RotateCw } from "lucide-react";
import { useDeferredValue } from "react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { useLibraryList } from "~/api/library.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ENTITY_STATUS, MEDIA_KIND, opts, SLOT_STATUS, SLOT_STATUS_ORDER } from "~/components/library/labels.ts";
import { CoverPlaceholder, relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
const FILTERS: { value: NonNullable<LibraryListQuery["filter"]>; label: string }[] = [
{ value: "gap", label: "有缺口" },
{ value: "attention", label: "需处理" },
{ value: "downloading", label: "下载中" },
{ value: "satisfied", label: "已满足" },
];
/** 各 Slot 状态计数:只列非零项,文字 + 语气 */
function SlotSummary({ counts }: { counts: LibraryItem["slotCounts"] }) {
const parts = SLOT_STATUS_ORDER.filter((s) => counts[s] > 0);
if (parts.length === 0) return <span className="text-meta text-muted-foreground">未设目标</span>;
return (
<div className="flex flex-wrap gap-1">
{parts.map((s: SlotStatus) => (
<StatusBadge key={s} tone={SLOT_STATUS[s].tone}>
{SLOT_STATUS[s].label} {counts[s]}
</StatusBadge>
))}
</div>
);
}
export default function LibraryPage() {
const [sp, setSp] = useSearchParams();
const navigate = useNavigate();
const kind = (sp.get("kind") ?? "") as MediaKind | "";
const filter = (sp.get("filter") ?? "") as LibraryListQuery["filter"] | "";
const q = sp.get("q") ?? "";
const deferredQ = useDeferredValue(q.trim());
const set = (k: string, v: string) =>
setSp(
(prev) => {
const next = new URLSearchParams(prev);
if (v) next.set(k, v);
else next.delete(k);
return next;
},
{ replace: true },
);
const list = useLibraryList({
kind: kind || undefined,
filter: filter || undefined,
q: deferredQ || undefined,
});
const rows = list.data?.pages.flatMap((p) => p.items);
const filtered = !!(kind || filter || q);
const columns: DataColumn<LibraryItem>[] = [
{
key: "title",
header: "作品",
cell: (r) => (
<div className="flex min-w-0 items-center gap-3">
<CoverPlaceholder title={r.entity.title} kind={r.entity.kind} small className="h-14 w-10" />
<div className="min-w-0">
<Link
to={consolePath("library", r.entity.id)}
onClick={(e) => e.stopPropagation()}
className="block max-w-[14rem] truncate font-medium text-foreground hover:underline sm:max-w-xs"
>
{r.entity.title}
</Link>
<p className="truncate text-meta text-muted-foreground">
{MEDIA_KIND[r.entity.kind]}
{r.entity.year ? ` · ${r.entity.year}` : ""}
{r.entity.originalTitle && r.entity.originalTitle !== r.entity.title
? ` · ${r.entity.originalTitle}`
: ""}
</p>
{r.entity.status !== "active" ? <Tone v={ENTITY_STATUS[r.entity.status]} className="mt-1" /> : null}
</div>
</div>
),
},
{ key: "slots", header: "版本目标", cell: (r) => <SlotSummary counts={r.slotCounts} /> },
{
key: "assets",
header: "资产",
align: "right",
hideBelow: "sm",
cell: (r) => <span className="tabular-nums">{r.assetCount}</span>,
},
{
key: "dl",
header: "下载中",
align: "right",
hideBelow: "md",
cell: (r) =>
r.activeDownloads ? (
<span className="inline-flex items-center gap-1 tabular-nums">
<Download className="h-3.5 w-3.5 text-muted-foreground" />
{r.activeDownloads}
</span>
) : (
<span className="text-muted-foreground">—</span>
),
},
{
key: "org",
header: "最近入库",
hideBelow: "lg",
cell: (r) => <span className="text-meta text-muted-foreground">{relTime(r.lastOrganizedAt)}</span>,
},
];
return (
<>
<PageHeader
title="媒体库"
actions={
<Button variant="outline" size="sm" onClick={() => list.refetch()} disabled={list.isFetching}>
<RotateCw className={list.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
/>
<FilterBar
search={{ value: q, onChange: (v) => set("q", v), placeholder: "标题 / 原名 / 别名" }}
onReset={filtered ? () => setSp(new URLSearchParams(), { replace: true }) : undefined}
>
<FilterSelect label="类型" value={kind} onChange={(v) => set("kind", v)} options={opts(MEDIA_KIND)} />
<FilterSelect label="状态" value={filter ?? ""} onChange={(v) => set("filter", v)} options={FILTERS} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.entity.id}
onRowClick={(r) => navigate(consolePath("library", r.entity.id))}
loading={list.isPending}
error={list.error}
onRetry={() => list.refetch()}
empty={
filtered
? { title: "没有符合条件的作品", description: "换个关键字或清除筛选。", icon: LibraryIcon }
: {
title: "媒体库为空",
description: "扫描库根或新建订阅后,作品会出现在这里。",
icon: LibraryIcon,
action: (
<Button asChild variant="outline" size="sm">
<Link to={consolePath("subscriptions")}>新建订阅</Link>
</Button>
),
}
}
hasMore={!!list.hasNextPage}
onLoadMore={() => list.fetchNextPage()}
loadingMore={list.isFetchingNextPage}
/>
</>
);
}