Files
UHD_Media_Tool/apps/web/app/components/library/subscriptions-slots.tsx
T
2026-09-28 02:09:39 +08:00

237 lines
8.4 KiB
TypeScript

import type { AcquisitionSlot } from "@umt/contracts";
import { Pencil, Plus, Target } from "lucide-react";
import { useId, useState } from "react";
import { toast } from "sonner";
import { z } from "zod";
import { useCreateSlot, useSlots, useUpdateSlot } from "~/api/slots.ts";
import { DetailSheet } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { ApiRequestError, errorMessage } from "~/lib/api.ts";
import { formatBytes } from "~/lib/utils.ts";
import { SlotCard } from "./slot-card.tsx";
import { draftFromJson, draftToJson, emptyDraft, type SlotRulesDraft, SlotRulesEditor } from "./slot-rules.tsx";
// 订阅页的 Slot 编辑:列出作品的收录目标,新建 / 编辑规则(乐观并发 expectedRevision)。
const SlotMeta = z.object({
name: z
.string()
.trim()
.min(1, "填写名称")
.max(64, "最多 64 个字符")
.regex(/^[\w\-.+ ]+$/u, "只用字母、数字、空格和 - _ . +"),
distinctGroup: z.string().trim().max(64, "最多 64 个字符"),
});
/** 409:别人(或状态机)已改过这个 Slot */
export function conflictToast(err: unknown, onRefresh: () => void) {
if (err instanceof ApiRequestError && (err.code === "conflict" || err.code === "precondition_failed")) {
toast.error("规则已被修改,已刷新。在最新版本上重新编辑。", { duration: 4000 });
onRefresh();
return;
}
toast.error(errorMessage(err));
}
export function SlotsPanel({
mediaEntityId,
subscriptionId,
}: {
mediaEntityId: string;
subscriptionId?: string | null;
}) {
const q = useSlots({ mediaEntityId });
const [editing, setEditing] = useState<AcquisitionSlot | "new" | null>(null);
const slots = q.data?.items ?? [];
return (
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-meta text-muted-foreground">每个版本目标单独判定和升级。这里只改规则。</p>
<Button variant="outline" size="sm" onClick={() => setEditing("new")}>
<Plus />
新建版本目标
</Button>
</div>
{q.isPending ? (
<LoadingState rows={2} />
) : q.isError ? (
<ErrorState plain error={q.error} onRetry={() => q.refetch()} />
) : slots.length === 0 ? (
<EmptyState
plain
icon={Target}
title="还没有版本目标"
description="没有时按已发布收录策略的默认模板创建,也可以手动新建。"
className="py-8"
/>
) : (
<div className="space-y-3">
{slots.map((s) => (
<SlotCard
key={s.id}
slot={s}
currentAsset={
s.currentAsset ? (
<span>
{[s.currentAsset.resolution, s.currentAsset.sourceType, s.currentAsset.releaseGroup]
.filter(Boolean)
.join(" · ") || "交付特征未知"}
{s.currentAsset.sizeBytes ? ` · ${formatBytes(s.currentAsset.sizeBytes, 1)}` : ""}
{s.currentAsset.status !== "active" ? ` · ${s.currentAsset.status}` : ""}
</span>
) : s.currentAssetId ? (
<span className="font-mono text-meta">{s.currentAssetId}</span>
) : undefined
}
actions={
<Button variant="ghost" size="icon-sm" aria-label={`编辑 ${s.name}`} onClick={() => setEditing(s)}>
<Pencil />
</Button>
}
/>
))}
</div>
)}
{editing ? (
<SlotEditorSheet
key={editing === "new" ? "new" : `${editing.id}-${editing.revision}`}
slot={editing === "new" ? null : editing}
mediaEntityId={mediaEntityId}
subscriptionId={subscriptionId ?? null}
onClose={() => setEditing(null)}
onConflict={() => q.refetch()}
/>
) : null}
</div>
);
}
function SlotEditorSheet({
slot,
mediaEntityId,
subscriptionId,
onClose,
onConflict,
}: {
slot: AcquisitionSlot | null;
mediaEntityId: string;
subscriptionId: string | null;
onClose: () => void;
onConflict: () => void;
}) {
const id = useId();
const create = useCreateSlot();
const update = useUpdateSlot();
const [name, setName] = useState(slot?.name ?? "");
const [distinctGroup, setDistinctGroup] = useState(slot?.distinctGroup ?? "");
const [requireDistinct, setRequireDistinct] = useState(slot?.requireDistinctAsset ?? false);
const [draft, setDraft] = useState<SlotRulesDraft>(() => (slot ? draftFromJson(slot) : emptyDraft()));
const [errors, setErrors] = useState<Record<string, string>>({});
const pending = create.isPending || update.isPending;
const submit = () => {
const meta = SlotMeta.safeParse({ name, distinctGroup });
const rules = draftToJson(draft);
const errs: Record<string, string> = {};
if (!meta.success) for (const i of meta.error.issues) errs[String(i.path[0])] ??= i.message;
if (!rules.ok) Object.assign(errs, rules.errors);
if (requireDistinct && !distinctGroup.trim()) errs.distinctGroup = "要求不同物理资产时须填写分组";
setErrors(errs);
if (Object.keys(errs).length || !meta.success || !rules.ok) return;
const common = {
...rules.value,
distinctGroup: meta.data.distinctGroup || null,
requireDistinctAsset: requireDistinct,
};
if (slot) {
update.mutate(
{ id: slot.id, expectedRevision: slot.revision, ...common },
{
onSuccess: (s) => {
toast.success(`已保存 ${s.name}(r${s.revision})`);
onClose();
},
onError: (e) => conflictToast(e, onConflict),
},
);
} else {
create.mutate(
{ mediaEntityId, subscriptionId, name: meta.data.name, ...common },
{
onSuccess: (s) => {
toast.success(`已新建 ${s.name}`);
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
},
);
}
};
return (
<DetailSheet
open
onOpenChange={(o) => !o && !pending && onClose()}
width="lg"
title={slot ? `编辑版本目标 ${slot.name}` : "新建版本目标"}
description={
slot ? `当前 r${slot.revision}。保存时若已被修改,会提示刷新。` : "不满足硬条件的资源不收;软偏好决定先收哪个。"
}
footer={
<>
<Button variant="outline" onClick={onClose} disabled={pending}>
取消
</Button>
<Button onClick={submit} disabled={pending}>
{pending ? "保存中…" : "保存"}
</Button>
</>
}
>
<div className="grid gap-3 sm:grid-cols-2">
<FormField
label="目标名称"
htmlFor={`${id}-name`}
required
error={errors.name}
hint={slot ? "名称创建后不可改" : "如 archival / streaming"}
>
<Input
id={`${id}-name`}
value={name}
disabled={!!slot}
aria-invalid={!!errors.name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<FormField
label="组合分组"
htmlFor={`${id}-grp`}
error={errors.distinctGroup}
hint="同组目标可要求由不同文件满足"
>
<Input
id={`${id}-grp`}
value={distinctGroup}
aria-invalid={!!errors.distinctGroup}
onChange={(e) => setDistinctGroup(e.target.value)}
/>
</FormField>
</div>
<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={requireDistinct} onCheckedChange={setRequireDistinct} aria-label="要求不同物理资产" />
</div>
<SlotRulesEditor value={draft} onChange={setDraft} errors={errors} />
</DetailSheet>
);
}