Files
UHD_Media_Tool/apps/web/app/components/library/entity-dialogs.tsx
T
rootandClaude Opus 5.5 52402864d6 style(copy): punctuation rules and site-wide fix with a CI check (QA U11)
- glossary: 10 punctuation rules (full-width in Chinese, CJK/Latin spacing,
  full-width parens around Chinese, single ellipsis, 「」 quotes, period rules)
- scripts/check-copy-punctuation.mjs: checks string literals, templates and JSX
  text (not comments, types, keys, match/compare strings); --fix, --strict,
  --by-file; wired into deno task ci as check:copy in warn mode
- --fix applied to web, server and contracts; files other lanes were editing
  (subscriptions, sites, browser, agent, auth/account, inventory) left for them
- tests updated for the changed messages

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