feat(web): W3-2 library, subscriptions and rules, preferences, decisions inbox, identity and metadata
- library list (kind/status/search, cursor paging) and detail: paths and roots, external identity mapping with lock, field-level metadata sources and locks, slot cards with rejection reasons, content units (provisional flag), downloads with verification; remap/lock/refresh/merge actions - subscriptions: list/create/update, slot rules editor (hard/soft/upgrade/completion), policy revision draft -> preview -> publish (grant warning) -> rollback - preferences: tag CRUD, disable/enable, suppression records and lift, history timeline - inbox: grouped by entity and kind, evidence/suggestion/impact/preview, resolve options, SSE refresh - identity: pool assign/dismiss/retry, external identity revisions and lock, title alias management - all responses validated against contracts zod schemas Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e5e42b1f0d
commit
ef73abe3df
25 files changed
+6670
-19
No files matched your search
@@ -1,6 +1,169 @@
|
||||
import { WorkspacePlaceholder } from "~/components/console/workspace-placeholder.tsx";
|
||||
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";
|
||||
|
||||
// 占位:标题与说明取自 components/console/nav.ts
|
||||
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() {
|
||||
return <WorkspacePlaceholder />;
|
||||
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="媒体库"
|
||||
description="已登记资产或正在追踪的作品;每个作品按独立收录目标(Slot)显示满足情况"
|
||||
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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user