Files
UHD_Media_Tool/apps/web/app/components/library/entity-dialogs.tsx
T
rootandClaude Opus 5.5 ef73abe3df 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]>
2026-09-27 20:39:02 +08:00

440 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
EntityIdentitySet,
type EntityMetadataField,
type ExternalIdentity,
type MediaEntity,
type MediaKind,
} from "@umt/contracts";
import { Check, LoaderCircle, Search } from "lucide-react";
import { type FormEvent, useId, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { z } from "zod";
import { useEntitySearch, useLockField, useMergeEntity, useSetEntityIdentity } from "~/api/library.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { ENTITY_FIELD_LABEL, EVIDENCE_LEVEL, IDENTITY_STATUS, MEDIA_KIND } from "./labels.ts";
import { Tone } from "./parts.tsx";
// 作品详情页的三个操作对话框:改外部映射 / 字段覆盖并锁定 / 合并到另一作品。
// ── 改外部映射 ──
const IdentityForm = EntityIdentitySet.extend({
externalId: z.string().trim().regex(/^\d+$/, "TMDB id 是纯数字"),
});
export function IdentityDialog({
entityId,
entityKind,
open,
onOpenChange,
candidates,
}: {
entityId: string;
entityKind: MediaKind;
open: boolean;
onOpenChange: (o: boolean) => void;
/** 可直接选用的候选(proposed / conflicted 映射) */
candidates: ExternalIdentity[];
}) {
const id = useId();
const set = useSetEntityIdentity(entityId);
const [pick, setPick] = useState<string | null>(null);
const [entityType, setEntityType] = useState<"movie" | "tv">(entityKind === "movie" ? "movie" : "tv");
const [externalId, setExternalId] = useState("");
const [lock, setLock] = useState(true);
const [reason, setReason] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const choose = (c: ExternalIdentity | null) => {
setPick(c?.id ?? null);
if (c && (c.entityType === "movie" || c.entityType === "tv")) {
setEntityType(c.entityType);
setExternalId(c.externalId);
}
};
const submit = (e: FormEvent) => {
e.preventDefault();
const r = IdentityForm.safeParse({
provider: "tmdb",
entityType,
externalId,
lock,
reason: reason.trim() || undefined,
});
if (!r.success) {
setErrors(zodFieldErrors(r.error.issues));
return;
}
setErrors({});
set.mutate(r.data, {
onSuccess: () => {
toast.success(`已改为 TMDB ${r.data.entityType}:${r.data.externalId}${r.data.lock ? ",并加人工锁" : ""}`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "改映射失败")),
});
};
const usable = candidates.filter((c) => c.provider === "tmdb" && (c.entityType === "movie" || c.entityType === "tv"));
return (
<Dialog open={open} onOpenChange={(o) => !set.isPending && onOpenChange(o)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>改外部映射</DialogTitle>
<DialogDescription>
旧的同类型 TMDB 映射会退役并留下修订记录;新映射直接确认。加人工锁后自动匹配与刷新不再改动它。
</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
{usable.length ? (
<div className="grid gap-1.5">
<p className="text-caption font-medium text-foreground">从候选中选</p>
<ul className="grid gap-1.5">
{usable.map((c) => (
<li key={c.id}>
<button
type="button"
onClick={() => choose(pick === c.id ? null : c)}
aria-pressed={pick === c.id}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-left transition-colors duration-micro",
pick === c.id ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
{pick === c.id ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
<span className="min-w-0 flex-1 font-mono text-caption break-all">
tmdb:{c.entityType}:{c.externalId}
</span>
<Tone v={IDENTITY_STATUS[c.status]} />
</button>
</li>
))}
</ul>
</div>
) : null}
<div className="grid gap-3 sm:grid-cols-[8rem_1fr]">
<FormField label="类型" htmlFor={`${id}-type`}>
<Select value={entityType} onValueChange={(v) => setEntityType(v as "movie" | "tv")}>
<SelectTrigger id={`${id}-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={`${id}-ext`} error={errors.externalId} required>
<Input
id={`${id}-ext`}
inputMode="numeric"
value={externalId}
aria-invalid={!!errors.externalId}
onChange={(e) => {
setExternalId(e.target.value);
setPick(null);
}}
placeholder="如 693134"
/>
</FormField>
</div>
<FormField label="原因" htmlFor={`${id}-reason`} error={errors.reason} hint="写进修订历史">
<Textarea id={`${id}-reason`} value={reason} onChange={(e) => setReason(e.target.value)} rows={2} />
</FormField>
<div className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="text-caption font-medium text-foreground">加人工锁</p>
<p className="text-meta text-muted-foreground">自动匹配不得覆盖人工锁定的关系</p>
</div>
<Switch checked={lock} onCheckedChange={setLock} aria-label="加人工锁" />
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={set.isPending}>
取消
</Button>
<Button type="submit" disabled={set.isPending}>
{set.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存映射
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ── 字段覆盖并锁定 ──
const FieldForm = z.object({
value: z.string().trim().min(1, "请填写值"),
reason: z.string().max(2000).optional(),
});
export function FieldLockDialog({
entityId,
field,
current,
onOpenChange,
}: {
entityId: string;
/** null 表示关闭 */
field: EntityMetadataField | null;
current: unknown;
onOpenChange: (o: boolean) => void;
}) {
const id = useId();
const lock = useLockField(entityId);
const [value, setValue] = useState("");
const [reason, setReason] = useState("");
const [errors, setErrors] = useState<Record<string, string>>({});
const [lastField, setLastField] = useState<string | null>(null);
if (field !== lastField) {
setLastField(field);
setValue(current === null || current === undefined ? "" : String(current));
setReason("");
setErrors({});
}
const submit = (e: FormEvent) => {
e.preventDefault();
if (!field) return;
const r = FieldForm.safeParse({ value, reason: reason.trim() || undefined });
if (!r.success) {
setErrors(zodFieldErrors(r.error.issues));
return;
}
let v: unknown = r.data.value;
if (field === "year") {
const n = Number(r.data.value);
if (!Number.isInteger(n) || n < 1870 || n > 2200) {
setErrors({ value: "年份需为 1870–2200 的整数" });
return;
}
v = n;
}
lock.mutate(
{ field, value: v, reason: r.data.reason },
{
onSuccess: () => {
toast.success(`已锁定「${ENTITY_FIELD_LABEL[field] ?? field}」`);
onOpenChange(false);
},
onError: (err) => toast.error(errorMessage(err, "锁定失败")),
},
);
};
const long = field === "overview";
return (
<Dialog open={!!field} onOpenChange={(o) => !lock.isPending && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>锁定字段:{field ? (ENTITY_FIELD_LABEL[field] ?? field) : ""}</DialogTitle>
<DialogDescription>保存为人工值并加锁,之后刷新元数据不会改写这个字段;不影响已绑定的路径。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
<FormField label="值" htmlFor={`${id}-v`} error={errors.value} required>
{long ? (
<Textarea
id={`${id}-v`}
rows={4}
value={value}
aria-invalid={!!errors.value}
onChange={(e) => setValue(e.target.value)}
/>
) : (
<Input
id={`${id}-v`}
value={value}
inputMode={field === "year" ? "numeric" : undefined}
aria-invalid={!!errors.value}
onChange={(e) => setValue(e.target.value)}
/>
)}
</FormField>
<FormField label="原因" htmlFor={`${id}-r`} error={errors.reason}>
<Input id={`${id}-r`} value={reason} onChange={(e) => setReason(e.target.value)} />
</FormField>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={lock.isPending}>
取消
</Button>
<Button type="submit" disabled={lock.isPending}>
{lock.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存并锁定
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
// ── 合并到另一作品 ──
/** 作品检索选择(合并目标等):输入即搜,点选一项 */
export function EntityPicker({
value,
onChange,
excludeId,
kind,
}: {
value: MediaEntity | null;
onChange: (e: MediaEntity | null) => void;
excludeId?: string;
kind?: MediaKind;
}) {
const [q, setQ] = useState("");
const search = useEntitySearch(q, { kind });
const items = (search.data?.items ?? []).filter((e) => e.id !== excludeId && e.status !== "merged");
return (
<div className="grid gap-2">
<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
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索作品标题"
className="pl-9"
aria-label="搜索作品"
/>
</div>
<div className="max-h-56 overflow-y-auto rounded-lg border border-border">
{search.isPending ? (
<p className="px-3 py-3 text-meta text-muted-foreground">搜索中…</p>
) : search.isError ? (
<p className="px-3 py-3 text-meta text-destructive">{errorMessage(search.error, "搜索失败")}</p>
) : items.length === 0 ? (
<p className="px-3 py-3 text-meta text-muted-foreground">没有匹配的作品</p>
) : (
<ul>
{items.map((e) => {
const on = value?.id === e.id;
return (
<li key={e.id} className="border-b border-border/60 last:border-b-0">
<button
type="button"
aria-pressed={on}
onClick={() => onChange(on ? null : e)}
className={cn(
"flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-left transition-colors duration-micro",
on ? "bg-foreground/[0.06]" : "hover:bg-foreground/[0.04]",
)}
>
{on ? <Check className="h-4 w-4 shrink-0" /> : <span className="h-4 w-4 shrink-0" />}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm text-foreground">{e.title}</span>
<span className="block truncate text-meta text-muted-foreground">
{MEDIA_KIND[e.kind]}
{e.year ? ` · ${e.year}` : ""}
{e.originalTitle ? ` · ${e.originalTitle}` : ""}
</span>
</span>
</button>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
export function MergeDialog({
entity,
open,
onOpenChange,
}: {
entity: MediaEntity;
open: boolean;
onOpenChange: (o: boolean) => void;
}) {
const navigate = useNavigate();
const merge = useMergeEntity(entity.id);
const [target, setTarget] = useState<MediaEntity | null>(null);
const [reason, setReason] = useState("");
const [confirm, setConfirm] = useState(false);
return (
<>
<Dialog open={open && !confirm} onOpenChange={(o) => !o && onOpenChange(false)}>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>合并到另一作品</DialogTitle>
<DialogDescription>
「{entity.title}」会标为已合并,旧 ID 跳转到目标作品;订阅、资产与历史引用一并转到目标。错误合并可以再修订。
</DialogDescription>
</DialogHeader>
<EntityPicker value={target} onChange={setTarget} excludeId={entity.id} />
<FormField label="原因" htmlFor="merge-reason" hint="写入审计与跳转记录">
<Input
id="merge-reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder="重复建档"
/>
</FormField>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button disabled={!target} onClick={() => setConfirm(true)}>
下一步
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={confirm}
onOpenChange={(o) => {
setConfirm(o);
if (!o && !merge.isSuccess) return;
}}
title="确认合并?"
description={
target
? `把「${entity.title}」合并到「${target.title}${target.year ? ` (${target.year})` : ""}」。合并后本页将跳转到目标作品。`
: ""
}
confirmLabel="合并"
destructive
onConfirm={async () => {
if (!target) return;
try {
await merge.mutateAsync({ intoId: target.id, reason: reason.trim() || "控制台合并" });
toast.success("已合并");
onOpenChange(false);
navigate(consolePath("library", target.id));
} catch (err) {
toast.error(errorMessage(err, "合并失败"));
throw err;
}
}}
/>
</>
);
}
export const evidenceLabel = (l: string) => EVIDENCE_LEVEL[l] ?? l;