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
@@ -0,0 +1,90 @@
|
||||
import type { MediaKind } from "@umt/contracts";
|
||||
import { Check, Search, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEntitySearch } from "~/api/library.ts";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { MEDIA_KIND } from "./labels.ts";
|
||||
|
||||
export type PickedEntity = { id: string; title: string; year: number | null; kind: MediaKind };
|
||||
|
||||
/** 本地作品选择:输入关键字检索 media_entity,点选一项 */
|
||||
export function EntityPicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "搜索本地作品(标题/原名)",
|
||||
id,
|
||||
}: {
|
||||
value: PickedEntity | null;
|
||||
onChange: (v: PickedEntity | null) => void;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
}) {
|
||||
const [q, setQ] = useState("");
|
||||
const [debounced, setDebounced] = useState("");
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setDebounced(q), 250);
|
||||
return () => clearTimeout(t);
|
||||
}, [q]);
|
||||
const search = useEntitySearch(debounced, { enabled: !value && debounced.trim().length > 0 });
|
||||
|
||||
if (value) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2 rounded-lg border border-border px-3 py-2">
|
||||
<Check className="h-4 w-4 shrink-0 text-success" />
|
||||
<span className="min-w-0 flex-1 truncate text-sm text-foreground">
|
||||
{value.title}
|
||||
{value.year ? ` (${value.year})` : ""} · {MEDIA_KIND[value.kind]}
|
||||
</span>
|
||||
<Button type="button" variant="ghost" size="icon-sm" aria-label="清除选择" onClick={() => onChange(null)}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const items = search.data?.items ?? [];
|
||||
return (
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input id={id} value={q} onChange={(e) => setQ(e.target.value)} placeholder={placeholder} className="pl-9" />
|
||||
</div>
|
||||
{debounced.trim() ? (
|
||||
<div className="max-h-48 overflow-y-auto rounded-lg border border-border">
|
||||
{search.isPending ? (
|
||||
<LoadingState rows={2} className="p-2" />
|
||||
) : search.isError ? (
|
||||
<p className="p-3 text-meta text-destructive">{(search.error as Error).message}</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="p-3 text-meta text-muted-foreground">没有匹配的本地作品</p>
|
||||
) : (
|
||||
<ul>
|
||||
{items.map((e) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ id: e.id, title: e.title, year: e.year, kind: e.kind });
|
||||
setQ("");
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left text-sm transition-colors duration-micro hover:bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate text-foreground">
|
||||
{e.title}
|
||||
{e.year ? ` (${e.year})` : ""}
|
||||
</span>
|
||||
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user