180 lines
6.6 KiB
TypeScript
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>
|
|
);
|
|
}
|