Files
UHD_Media_Tool/apps/web/app/components/library/entity-dialogs.tsx
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +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;