feat(identity): open identity_unresolved decisions for resources with no candidates

no_match resources (and lookup failures after auto-review gives up) now
open a decision in the inbox with options to set a TMDB ID, pick a local
work, or dismiss; ambiguous decisions get the same manual options.
Resolve accepts identity {tmdb | mediaEntityId} and assigns with a manual
lock. The inbox option dialog collects the TMDB type/ID or searches local
works; the identity pool links each pending item to its inbox filter.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 13:44:57 +08:00
1 parent a0b0a64246
commit fb7727cf66
14 files changed
+280 -37

No files matched your search

+8 -2
View File
@@ -14,7 +14,13 @@ 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 { INBOX_IDENTITY, inInbox, PoolSheet, querySummary } from "~/components/library/identity-pool-sheet.tsx";
import {
INBOX_IDENTITY,
inboxLink,
inInbox,
PoolSheet,
querySummary,
} from "~/components/library/identity-pool-sheet.tsx";
import {
EVIDENCE_LEVEL,
IDENTITY_STATUS,
@@ -77,7 +83,7 @@ function PoolTab() {
<Tone v={POOL_REASON[r.reason]} />
{inInbox(r) ? (
<Link
to={INBOX_IDENTITY}
to={inboxLink(r)}
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"
>
+62 -8
View File
@@ -13,9 +13,11 @@ import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { EntityPicker, type PickedEntity } from "~/components/library/identity-entity-picker.tsx";
import { DECISION_KIND, DECISION_STATUS, opts, SEVERITY } from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse, useSseStatus } from "~/lib/sse.ts";
@@ -99,9 +101,31 @@ function Evidence({ d }: { d: Decision }) {
);
}
/** 身份类手动指定选项需要的输入(后端在 option.input.requires 标注) */
type Requires = "tmdb" | "mediaEntityId" | null;
function requiresOf(option: DecisionOption): Requires {
const r = option.input?.requires;
return r === "tmdb" || r === "mediaEntityId" ? r : null;
}
function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
const resolve = useResolveDecision();
const [note, setNote] = useState("");
const requires = requiresOf(option);
const [tmdbType, setTmdbType] = useState<"movie" | "tv">("movie");
const [tmdbId, setTmdbId] = useState("");
const [entity, setEntity] = useState<PickedEntity | null>(null);
const tmdbOk = /^\d{1,10}$/.test(tmdbId.trim());
const identity =
requires === "tmdb"
? tmdbOk
? { tmdb: { type: tmdbType, id: tmdbId.trim() } }
: null
: requires === "mediaEntityId"
? entity
? { mediaEntityId: entity.id }
: null
: undefined;
return (
<ConfirmDialog
trigger={
@@ -117,16 +141,46 @@ function OptionButton({ d, option }: { d: Decision; option: DecisionOption }) {
</>
}
confirmLabel={option.label}
confirmDisabled={identity === null}
onConfirm={() =>
resolve.mutateAsync({ id: d.id, optionKey: option.key, note: note.trim() || undefined }).then(
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
resolve
.mutateAsync({
id: d.id,
optionKey: option.key,
note: note.trim() || undefined,
...(identity ? { identity } : {}),
})
.then(
() => toast.success(`已处理:${option.label}`),
(err) => {
toast.error(errorMessage(err, "处理失败"));
throw err;
},
)
}
>
{requires === "tmdb" ? (
<div className="flex gap-2">
<select
aria-label="TMDB 类型"
value={tmdbType}
onChange={(e) => setTmdbType(e.target.value as "movie" | "tv")}
className="h-9 shrink-0 rounded-lg border border-input bg-transparent px-2 text-caption"
>
<option value="movie">电影</option>
<option value="tv">剧集</option>
</select>
<Input
aria-label="TMDB ID"
inputMode="numeric"
value={tmdbId}
onChange={(e) => setTmdbId(e.target.value)}
placeholder="TMDB ID,如 872585"
/>
</div>
) : requires === "mediaEntityId" ? (
<EntityPicker value={entity} onChange={setEntity} />
) : null}
<Textarea value={note} onChange={(e) => setNote(e.target.value)} placeholder="备注(可选)" rows={2} />
</ConfirmDialog>
);
@@ -366,7 +420,7 @@ export default function InboxPage() {
</Link>
) : (
<span className="text-body font-semibold text-muted-foreground">
{g.kind === "identity_ambiguous" ? "尚未归属作品" : "系统级"}
{g.kind === "identity_ambiguous" || g.kind === "identity_unresolved" ? "尚未归属作品" : "系统级"}
</span>
)}
<span className="ml-auto text-meta text-muted-foreground">{g.items.length} 项</span>