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:
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user