feat(web): MP-style media detail with seasons and episodes, shared by library and TMDB preview (MD-1)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
9cd5a28943
commit
97ac59c7ac
9 files changed
+1250
-190
No files matched your search
@@ -246,6 +246,29 @@ export const ENTITY_FIELD_LABEL: Record<string, string> = {
|
||||
episodeCount: "集数",
|
||||
genres: "类型",
|
||||
posterUrl: "网页海报",
|
||||
titleZh: "中文名",
|
||||
tagline: "标语",
|
||||
lastAirDate: "最近播出",
|
||||
airStatus: "播出状态",
|
||||
voteAverage: "评分",
|
||||
voteCount: "评分人数",
|
||||
runtime: "时长(分钟)",
|
||||
seasonCount: "季数",
|
||||
cast: "演员",
|
||||
crew: "导演 / 主创",
|
||||
externalIds: "外部 ID",
|
||||
};
|
||||
|
||||
/** 季 / 集字段(contracts UnitMetadataField) */
|
||||
export const UNIT_FIELD_LABEL: Record<string, string> = {
|
||||
name: "标题",
|
||||
overview: "简介",
|
||||
airDate: "播出日期",
|
||||
runtime: "时长(分钟)",
|
||||
stillPath: "剧照",
|
||||
posterPath: "海报",
|
||||
voteAverage: "评分",
|
||||
voteCount: "评分人数",
|
||||
};
|
||||
|
||||
/** 补全字段(contracts EnrichFieldName) */
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user