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:
1 parent
a0b0a64246
commit
fb7727cf66
14 files changed
+280
-37
No files matched your search
@@ -27,6 +27,7 @@ export function ConfirmDialog({
|
||||
confirmLabel = "确认",
|
||||
cancelLabel = "取消",
|
||||
destructive = false,
|
||||
confirmDisabled = false,
|
||||
onConfirm,
|
||||
children,
|
||||
}: {
|
||||
@@ -38,6 +39,8 @@ export function ConfirmDialog({
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
destructive?: boolean;
|
||||
/** 输入未填完整时禁用确认按钮 */
|
||||
confirmDisabled?: boolean;
|
||||
onConfirm: () => unknown | Promise<unknown>;
|
||||
/** 描述下方的额外内容(输入确认等) */
|
||||
children?: ReactNode;
|
||||
@@ -74,7 +77,7 @@ export function ConfirmDialog({
|
||||
{children}
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={pending}>{cancelLabel}</AlertDialogCancel>
|
||||
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={pending}>
|
||||
<Button variant={destructive ? "destructive" : "default"} onClick={run} disabled={pending || confirmDisabled}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : null}
|
||||
{confirmLabel}
|
||||
</Button>
|
||||
|
||||
@@ -15,16 +15,22 @@ import { Chip, Tone } from "./parts.tsx";
|
||||
|
||||
// 待识别资源详情(只读):候选证据 + 重新识别。选候选、忽略在待处理里做(判定只在待处理与作品详情)。
|
||||
|
||||
/** 多个候选 / 待确认的资源会生成"待确认身份"待处理事项 */
|
||||
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 function inInbox(item: Pick<IdentityPoolItem, "status" | "reason" | "candidates" | "decisionId">): boolean {
|
||||
return item.status === "pending" && (item.decisionId !== null || hasCandidates(item));
|
||||
}
|
||||
|
||||
function hasCandidates(item: Pick<IdentityPoolItem, "reason" | "candidates">): boolean {
|
||||
return (item.reason === "ambiguous" || item.reason === "proposed") && item.candidates.length > 0;
|
||||
}
|
||||
|
||||
export const INBOX_IDENTITY = `${consolePath("inbox")}?kind=identity_ambiguous`;
|
||||
export const INBOX_UNRESOLVED = `${consolePath("inbox")}?kind=identity_unresolved`;
|
||||
|
||||
/** 条目对应的待处理筛选链接 */
|
||||
export function inboxLink(item: Pick<IdentityPoolItem, "reason" | "candidates">): string {
|
||||
return hasCandidates(item) ? INBOX_IDENTITY : INBOX_UNRESOLVED;
|
||||
}
|
||||
|
||||
/** query 摘要:标题 / 年份 / 季集 */
|
||||
export function querySummary(q: Record<string, unknown>): string {
|
||||
@@ -110,7 +116,7 @@ export function PoolSheet({ item, onClose }: { item: IdentityPoolItem | null; on
|
||||
</Button>
|
||||
{queued ? (
|
||||
<Button asChild>
|
||||
<Link to={INBOX_IDENTITY}>
|
||||
<Link to={inboxLink(item)}>
|
||||
去待处理
|
||||
<ArrowRight />
|
||||
</Link>
|
||||
|
||||
@@ -133,6 +133,7 @@ export const CANDIDATE_DECISION: Record<string, ToneLabel> = {
|
||||
|
||||
export const DECISION_KIND: Record<DecisionKind, string> = {
|
||||
identity_ambiguous: "身份歧义",
|
||||
identity_unresolved: "待识别",
|
||||
download_stalled: "下载无进展",
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
|
||||
@@ -38,6 +38,7 @@ export const downloadLabel = (v: string | null | undefined) => pick(DOWNLOAD, v)
|
||||
|
||||
const DECISION_KIND: Record<string, string> = {
|
||||
identity_ambiguous: "识别有歧义",
|
||||
identity_unresolved: "待识别",
|
||||
download_stalled: "下载无进展",
|
||||
download_failed: "下载失败",
|
||||
path_conflict: "路径冲突",
|
||||
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user