feat(web): read-only identity pool, decisions happen in 待处理

Pool detail keeps 重新识别 only; rows with an open identity decision show
已进待处理 and link to /console/inbox?kind=identity_ambiguous.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 02:46:55 +08:00
1 parent ddb15145d8
commit f38758bd26
2 files changed
+72 -160

No files matched your search

@@ -1,32 +1,30 @@
import type { IdentityPoolItem, PoolCandidate } from "@umt/contracts";
import { ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
import { type FormEvent, useState } from "react";
import { ArrowRight, ExternalLink, LoaderCircle, RotateCw } from "lucide-react";
import { Link } from "react-router";
import { toast } from "sonner";
import { z } from "zod";
import { useAssignPool, useDismissPool, useRetryPool } from "~/api/identity.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { useRetryPool } from "~/api/identity.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { JsonView } from "~/components/console/json-view.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { EntityPicker, type PickedEntity } from "./identity-entity-picker.tsx";
import { EVIDENCE_LEVEL, POOL_REASON, POOL_STATUS } from "./labels.ts";
import { Chip, Tone } from "./parts.tsx";
// 待识别条目详情:候选证据 + 三种人工指定方式(候选 / 手动 TMDB id / 本地作品)+ 忽略 / 重试。指定后默认加人工锁。
// 待识别资源详情(只读):候选证据 + 重新识别。选候选、忽略在待处理里做(判定只在待处理与作品详情)。
const TmdbForm = z.object({
entityType: z.enum(["movie", "tv"]),
externalId: z.string().trim().regex(/^\d+$/, "TMDB ID 只能是数字"),
});
/** 多个候选 / 待确认的资源会生成"待确认身份"待处理事项 */
export function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates">): boolean {
return (
item.status === "pending" &&
(item.reason === "ambiguous" || item.reason === "proposed") &&
item.candidates.length > 0
);
}
export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`;
/** query 摘要:标题 / 年份 / 季集 */
export function querySummary(q: Record<string, unknown>): string {
@@ -40,17 +38,7 @@ export function querySummary(q: Record<string, unknown>): string {
return parts.join(" · ") || "—";
}
function CandidateRow({
c,
index,
onPick,
disabled,
}: {
c: PoolCandidate;
index: number;
onPick: () => void;
disabled: boolean;
}) {
function CandidateRow({ c, index }: { c: PoolCandidate; index: number }) {
return (
<li className="min-w-0 rounded-lg border border-border/80 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
@@ -63,9 +51,6 @@ function CandidateRow({
{c.provider}:{c.entityType}:{c.externalId}
</p>
</div>
<Button size="sm" variant="outline" onClick={onPick} disabled={disabled}>
指定此候选
</Button>
</div>
<div className="mt-2 flex flex-wrap items-center gap-1.5">
<Chip>{EVIDENCE_LEVEL[c.level] ?? c.level}</Chip>
@@ -78,7 +63,7 @@ function CandidateRow({
<ExternalLink className="h-3 w-3" />
</Link>
) : (
<span className="text-meta text-muted-foreground">本地未建档,指定后按 TMDB 信息建档</span>
<span className="text-meta text-muted-foreground">本地未建档</span>
)}
</div>
{c.evidence.length ? (
@@ -95,41 +80,9 @@ function CandidateRow({
}
export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; onClose: () => void }) {
const assign = useAssignPool();
const dismiss = useDismissPool();
const retry = useRetryPool();
const [tmdb, setTmdb] = useState({ entityType: "movie" as "movie" | "tv", externalId: "" });
const [tmdbErr, setTmdbErr] = useState<Record<string, string>>({});
const [entity, setEntity] = useState<PickedEntity | null>(null);
const [note, setNote] = useState("");
if (!item) return null;
const pending = item.status === "pending";
const busy = assign.isPending || dismiss.isPending || retry.isPending;
const doAssign = async (body: Parameters<typeof assign.mutateAsync>[0], ok: string) => {
try {
await assign.mutateAsync(body);
toast.success(ok);
onClose();
} catch (err) {
toast.error(errorMessage(err));
}
};
const submitTmdb = (e: FormEvent) => {
e.preventDefault();
const r = TmdbForm.safeParse(tmdb);
if (!r.success) {
setTmdbErr(zodFieldErrors(r.error.issues));
return;
}
setTmdbErr({});
void doAssign(
{ id: item.id, external: { provider: "tmdb", ...r.data }, lock: true },
`已指定 TMDB ${r.data.entityType}:${r.data.externalId} 并加人工锁`,
);
};
const queued = inInbox(item);
return (
<DetailSheet
@@ -140,31 +93,9 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
description={item.release.subtitle ?? undefined}
footer={
<>
<ConfirmDialog
trigger={
<Button variant="outline" disabled={!pending || busy}>
忽略
</Button>
}
title="忽略这条待识别资源?"
description="忽略后不再自动复查,仍可按原始标题搜到。"
confirmLabel="忽略"
onConfirm={async () => {
try {
await dismiss.mutateAsync({ id: item.id, note: note.trim() || undefined });
toast.success("已忽略");
onClose();
} catch (err) {
toast.error(errorMessage(err));
throw err;
}
}}
>
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="忽略原因(可选)" rows={2} />
</ConfirmDialog>
<Button
variant="outline"
disabled={busy}
disabled={retry.isPending}
onClick={async () => {
try {
await retry.mutateAsync(item.id);
@@ -177,9 +108,22 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
{retry.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCw />}
重新识别
</Button>
{queued ? (
<Button asChild>
<Link to={INBOX_IDENTITY}>
去待处理
<ArrowRight />
</Link>
</Button>
) : null}
</>
}
>
{queued ? (
<p className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning">
已进待处理,在那里选择候选或忽略。
</p>
) : null}
<KvList
items={[
{ label: "状态", value: <Tone v={POOL_STATUS[item.status]} /> },
@@ -198,79 +142,14 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
{item.candidates.length ? (
<ul className="space-y-2">
{item.candidates.map((c, i) => (
<CandidateRow
key={`${c.provider}:${c.entityType}:${c.externalId}`}
c={c}
index={i}
disabled={!pending || busy}
onPick={() =>
doAssign({ id: item.id, candidateIndex: i, lock: true }, `已归入「${c.title}」并加人工锁`)
}
/>
<CandidateRow key={`${c.provider}:${c.entityType}:${c.externalId}`} c={c} index={i} />
))}
</ul>
) : (
<p className="text-caption text-muted-foreground">没有候选。手动指定 TMDB ID 或本地作品。</p>
<p className="text-caption text-muted-foreground">没有候选。有新证据时自动重试。</p>
)}
</DetailSection>
{pending ? (
<>
<DetailSection title="手动指定 TMDB">
<form onSubmit={submitTmdb} className="grid gap-2 sm:grid-cols-[8rem_1fr_auto] sm:items-start" noValidate>
<FormField label="类型" htmlFor="pool-tmdb-type">
<Select
value={tmdb.entityType}
onValueChange={(v) => setTmdb((t) => ({ ...t, entityType: v as "movie" | "tv" }))}
>
<SelectTrigger id="pool-tmdb-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
<SelectItem value="movie">电影 movie</SelectItem>
<SelectItem value="tv">剧集 tv</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField label="TMDB id" htmlFor="pool-tmdb-id" error={tmdbErr.externalId}>
<Input
id="pool-tmdb-id"
inputMode="numeric"
value={tmdb.externalId}
aria-invalid={!!tmdbErr.externalId}
onChange={(e) => setTmdb((t) => ({ ...t, externalId: e.target.value }))}
placeholder="如 693134"
/>
</FormField>
<Button type="submit" variant="outline" className="sm:mt-6" disabled={busy}>
指定
</Button>
</form>
</DetailSection>
<DetailSection title="指定本地作品">
<div className="grid gap-2">
<EntityPicker value={entity} onChange={setEntity} />
<div className="flex justify-end">
<Button
variant="outline"
disabled={!entity || busy}
onClick={() =>
entity &&
doAssign(
{ id: item.id, mediaEntityId: entity.id, lock: true },
`已归入「${entity.title}」并加人工锁`,
)
}
>
归入该作品
</Button>
</div>
</div>
</DetailSection>
</>
) : null}
<JsonView label="解析条件(原始)" value={item.query} defaultOpen={false} />
</DetailSheet>
);
+40 -7
View File
@@ -5,16 +5,16 @@ import type {
IdentityPoolItem,
IdentityStatus,
} from "@umt/contracts";
import { Fingerprint, Lock, SearchX } from "lucide-react";
import { ArrowRight, Fingerprint, Inbox, Lock, SearchX } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { Link, useSearchParams } from "react-router";
import { useExternalIdentities, useIdentityPool } from "~/api/identity.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { AliasesPanel } from "~/components/library/identity-aliases.tsx";
import { ExternalIdentitySheet, identityRef } from "~/components/library/identity-external-sheet.tsx";
import { PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
import { INBOX_IDENTITY, inInbox, PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
import {
EVIDENCE_LEVEL,
IDENTITY_STATUS,
@@ -24,9 +24,13 @@ import {
VERIFY_STATUS,
} from "~/components/library/labels.ts";
import { relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
// 身份与元数据(REQ §7.2、§7.5):待识别池、外部身份映射与修订、别名。
// 身份与元数据(REQ §7.2、§7.5):待识别资源、外部身份映射与修订、别名。
// 待识别资源只读;选候选、忽略在待处理里做(docs/design/console-ia.md §3.1 判定只在待处理)。
export const meta = () => [{ title: "身份与元数据 · UHD 媒体自动化控制台" }];
function useDebounced(v: string, ms = 250) {
const [d, setD] = useState(v);
@@ -65,7 +69,25 @@ function PoolTab() {
</div>
),
},
{ key: "reason", header: "原因", cell: (r) => <Tone v={POOL_REASON[r.reason]} /> },
{
key: "reason",
header: "原因",
cell: (r) => (
<div className="flex flex-col items-start gap-1">
<Tone v={POOL_REASON[r.reason]} />
{inInbox(r) ? (
<Link
to={INBOX_IDENTITY}
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-0.5 text-meta whitespace-nowrap text-muted-foreground underline-offset-4 hover:text-foreground hover:underline"
>
已进待处理
<ArrowRight className="h-3 w-3" />
</Link>
) : null}
</div>
),
},
{
key: "cands",
header: "候选",
@@ -251,7 +273,18 @@ export default function IdentityPage() {
const tab = (TABS as readonly string[]).includes(params.get("tab") ?? "") ? params.get("tab")! : "pool";
return (
<>
<PageHeader title="身份与元数据" description="以本地 ID 为准。自动匹配不覆盖人工锁定的外部身份。" />
<PageHeader
title="身份与元数据"
description="以本地 ID 为准。自动匹配不覆盖人工锁定的外部身份。"
actions={
<Button asChild variant="outline" size="sm">
<Link to={INBOX_IDENTITY}>
<Inbox />
待确认身份
</Link>
</Button>
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
@@ -265,7 +298,7 @@ export default function IdentityPage() {
}
>
<TabsList>
<TabsTrigger value="pool">待识别池</TabsTrigger>
<TabsTrigger value="pool">待识别</TabsTrigger>
<TabsTrigger value="external">外部身份</TabsTrigger>
<TabsTrigger value="aliases">别名</TabsTrigger>
</TabsList>