Files
UHD_Media_Tool/apps/web/app/components/library/unit-field-dialog.tsx
T

180 lines
6.6 KiB
TypeScript

import { type ContentUnit, FinalFieldValue, type UnitMetadataField } from "@umt/contracts";
import { LoaderCircle, Lock, LockOpen } from "lucide-react";
import { type FormEvent, useId, useState } from "react";
import { toast } from "sonner";
import { useLockUnitField, useUnlockUnitField } from "~/api/library.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
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 { Label } from "~/components/ui/label.tsx";
import { Textarea } from "~/components/ui/textarea.tsx";
import { errorMessage } from "~/lib/api.ts";
import { METADATA_SOURCE, UNIT_FIELD_LABEL } from "./labels.ts";
// 季 / 集字段覆盖与锁定(MD-1):改标题 / 简介 / 播出日期即锁定,元数据刷新不再改写;解锁回到来源值。
const EDITABLE: UnitMetadataField[] = ["name", "airDate", "overview"];
const pad = (n: number | null) => String(n ?? 0).padStart(2, "0");
export function finalOf(u: ContentUnit, field: string): FinalFieldValue | null {
const p = FinalFieldValue.safeParse((u.metadata as Record<string, unknown>)[field]);
return p.success ? p.data : null;
}
export function UnitFieldDialog({
entityId,
unit,
onOpenChange,
}: {
entityId: string;
/** null 表示关闭 */
unit: ContentUnit | null;
onOpenChange: (o: boolean) => void;
}) {
const id = useId();
const lock = useLockUnitField(entityId);
const unlock = useUnlockUnitField(entityId);
const [values, setValues] = useState<Record<string, string>>({});
const [error, setError] = useState<string | null>(null);
const [lastId, setLastId] = useState<string | null>(null);
if ((unit?.id ?? null) !== lastId) {
setLastId(unit?.id ?? null);
setError(null);
setValues(
Object.fromEntries(
EDITABLE.map((f) => {
const v = unit ? finalOf(unit, f)?.value : null;
return [f, typeof v === "string" ? v : ""];
}),
),
);
}
if (!unit) return <Dialog open={false} />;
const busy = lock.isPending || unlock.isPending;
const code = `S${pad(unit.seasonNumber)}E${pad(unit.episodeNumber)}`;
const submit = async (e: FormEvent) => {
e.preventDefault();
const changed = EDITABLE.filter((f) => {
const cur = finalOf(unit, f)?.value;
return (values[f] ?? "").trim() !== (typeof cur === "string" ? cur : "");
});
if (!changed.length) {
onOpenChange(false);
return;
}
const date = values.airDate?.trim();
if (changed.includes("airDate") && date && !/^\d{4}-\d{2}-\d{2}$/.test(date)) {
setError("播出日期格式是 YYYY-MM-DD");
return;
}
if (changed.includes("name") && !values.name?.trim()) {
setError("标题不能为空");
return;
}
try {
for (const f of changed) {
const v = (values[f] ?? "").trim();
await lock.mutateAsync({ unitId: unit.id, field: f, value: v || null });
}
toast.success(`已锁定 ${code} 的 ${changed.map((f) => UNIT_FIELD_LABEL[f]).join("、")}`);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
}
};
const locked = EDITABLE.filter((f) => unit.fieldLocks.includes(f));
return (
<Dialog open onOpenChange={(o) => !busy && onOpenChange(o)}>
<DialogContent>
<DialogHeader>
<DialogTitle>修改 {code}</DialogTitle>
<DialogDescription>改过的字段会锁定,刷新元数据不再改写。编号和文件位置不变。</DialogDescription>
</DialogHeader>
<form onSubmit={submit} className="grid gap-3">
{EDITABLE.map((f) => {
const fin = finalOf(unit, f);
const isLocked = unit.fieldLocks.includes(f);
return (
<div key={f} className="grid gap-1.5">
<div className="flex items-center justify-between gap-2">
<Label htmlFor={`${id}-${f}`}>{UNIT_FIELD_LABEL[f]}</Label>
<div className="flex items-center gap-1.5">
{fin ? (
<span className="text-meta text-muted-foreground">
来源 {METADATA_SOURCE[fin.source] ?? fin.source}
{fin.language ? ` · ${fin.language}` : ""}
</span>
) : null}
{isLocked ? (
<StatusBadge>
<Lock className="h-3 w-3" />
已锁定
</StatusBadge>
) : null}
</div>
</div>
{f === "overview" ? (
<Textarea
id={`${id}-${f}`}
rows={4}
value={values[f] ?? ""}
onChange={(e) => setValues((v) => ({ ...v, [f]: e.target.value }))}
/>
) : (
<Input
id={`${id}-${f}`}
value={values[f] ?? ""}
placeholder={f === "airDate" ? "YYYY-MM-DD" : undefined}
onChange={(e) => setValues((v) => ({ ...v, [f]: e.target.value }))}
/>
)}
</div>
);
})}
{error ? <p className="text-meta text-destructive">{error}</p> : null}
<DialogFooter className="flex-wrap gap-2">
{locked.length ? (
<Button
type="button"
variant="ghost"
disabled={busy}
className="sm:mr-auto"
onClick={async () => {
try {
for (const f of locked) await unlock.mutateAsync({ unitId: unit.id, field: f });
toast.success(`已解锁 ${code}`);
onOpenChange(false);
} catch (err) {
toast.error(errorMessage(err, "解锁失败"));
}
}}
>
<LockOpen />
全部解锁
</Button>
) : null}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={busy}>
取消
</Button>
<Button type="submit" disabled={busy}>
{lock.isPending ? <LoaderCircle className="animate-spin" /> : null}
保存并锁定
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}