Auto-fix of 602 string literals across 74 files (half-width punctuation next to Chinese, parenthesised Chinese, quotes); biome reflow; e2/e6 assertions follow the new full-width summaries. check:copy now runs with --strict in ci. Co-Authored-By: Claude Opus 5.5 <[email protected]>
237 lines
8.5 KiB
TypeScript
237 lines
8.5 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>
|
|
);
|
|
}
|