Files
UHD_Media_Tool/apps/web/app/components/subscriptions/slot-panel.tsx
T
rootandClaude Opus 5.5 fb84a71f80 style(copy): fix remaining punctuation violations and make check:copy strict
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]>
2026-09-28 21:34:51 +08:00

156 lines
5.9 KiB
TypeScript

import type { Subscription, SubscriptionCoverage, SubscriptionOverviewItem } from "@umt/contracts";
import { Check, Wand2 } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { useFillSlot, useSetSlotOrder, useUpdateSubscription } from "~/api/subscriptions.ts";
import { Panel } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { errorMessage } from "~/lib/api.ts";
import {
AUTOFILL_SOURCE,
type DraftErrors,
draftFromSubscription,
draftToParams,
type SubscriptionDraft,
} from "./model.ts";
import { SlotOrderList } from "./slot-order.tsx";
import { AutoFillFields } from "./subscription-form.tsx";
// 对比视图的"版本目标"区(设计 §2.2):拖动排序(PUT slot-order)、行尾计数、补全此版本、自动补全开关与预算。
type SlotRow = SubscriptionCoverage["rows"][number] & { id: string };
export function SlotPanel({
sub,
cov,
item,
canFill,
}: {
sub: Subscription;
cov: SubscriptionCoverage | undefined;
item: SubscriptionOverviewItem | null | undefined;
canFill: boolean;
}) {
const id = useId();
const setOrder = useSetSlotOrder();
const fill = useFillSlot();
const update = useUpdateSubscription();
const serverRows = useMemo<SlotRow[]>(
() => (cov?.rows ?? []).filter((r) => r.slotId !== null).map((r) => ({ ...r, id: r.slotId! })),
[cov],
);
const [rows, setRows] = useState<SlotRow[]>(serverRows);
useEffect(() => setRows(serverRows), [serverRows]);
const dirty = rows.map((r) => r.id).join() !== serverRows.map((r) => r.id).join();
const base = useMemo(() => draftFromSubscription(sub, "off"), [sub]);
const [af, setAf] = useState<Pick<SubscriptionDraft, "autoFill" | "unitsPerDay" | "gbPerDay">>(base);
useEffect(() => setAf(base), [base]);
const [errs, setErrs] = useState<DraftErrors>({});
const afDirty = af.autoFill !== base.autoFill || af.unitsPerDay !== base.unitsPerDay || af.gbPerDay !== base.gbPerDay;
const saveOrder = () =>
setOrder.mutate(
{ id: sub.id, slotIds: rows.map((r) => r.id) },
{
onSuccess: () => toast.success(`主版本:${rows[0]?.name ?? ""}`),
onError: (e) => {
toast.error(errorMessage(e));
setRows(serverRows);
},
},
);
const fillSlot = (r: SlotRow) =>
fill.mutate(
{ id: sub.id, slotId: r.id },
{
onSuccess: (x) => toast.success(x.deduplicated ? "补全任务已在排队" : `已开始补全「${r.name}」`),
onError: (e) => toast.error(errorMessage(e, "补全失败")),
},
);
const saveAutoFill = () => {
const { params, errors } = draftToParams({ ...base, ...af }, sub.params, {
upgradeChanged: false,
totalChanged: false,
});
setErrs(errors);
if (Object.keys(errors).length) return;
update.mutate(
{ id: sub.id, params },
{ onSuccess: () => toast.success("自动补全设置已保存"), onError: (e) => toast.error(errorMessage(e)) },
);
};
return (
<Panel
title="版本目标"
description="拖动调整顺序,第一个是主版本。主版本缺的照常追更;其他版本按下面的自动补全设置补齐。"
actions={
dirty ? (
<>
<Button variant="ghost" size="sm" onClick={() => setRows(serverRows)} disabled={setOrder.isPending}>
还原
</Button>
<Button size="sm" onClick={saveOrder} disabled={setOrder.isPending}>
<Check />
保存顺序
</Button>
</>
) : null
}
>
{rows.length === 0 ? (
<p className="text-caption text-muted-foreground">
{sub.mediaEntityId ? "还没有版本目标。在下方规则区新建。" : "作品建档后才有版本目标。"}
</p>
) : (
<SlotOrderList
items={rows}
onChange={setRows}
disabled={setOrder.isPending}
renderExtra={(r, i) => (
<span className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums max-sm:w-full max-sm:pl-9">
<span>已入库 {r.summary.have}</span>
<span className={r.summary.missing ? "text-destructive" : undefined}>缺 {r.summary.missing}</span>
<span>在途 {r.summary.inflight}</span>
{i === 0 ? null : <span>{r.autoFill ? "自动补全" : "只登记缺失"}</span>}
<Button
variant="outline"
size="xs"
onClick={() => fillSlot(r)}
disabled={!canFill || fill.isPending || r.summary.missing === 0}
title={canFill ? undefined : "订阅不在追踪中,先恢复"}
>
<Wand2 />
补全此版本
</Button>
</span>
)}
/>
)}
<div className="mt-5 space-y-2 border-t border-border/60 pt-4">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<h3 className="text-sm font-semibold text-foreground">自动补全其他版本</h3>
{item ? (
<span className="text-meta text-muted-foreground">
当前{item.autoFill.enabled ? "开启" : "关闭"} · 来自{AUTOFILL_SOURCE[item.autoFill.source]}
</span>
) : null}
</div>
<AutoFillFields d={af} patch={(p) => setAf((x) => ({ ...x, ...p }))} errors={errs} idPrefix={id} />
{afDirty ? (
<div className="flex justify-end gap-2">
<Button variant="ghost" size="sm" onClick={() => setAf(base)} disabled={update.isPending}>
还原
</Button>
<Button size="sm" onClick={saveAutoFill} disabled={update.isPending}>
<Check />
保存
</Button>
</div>
) : null}
</div>
</Panel>
);
}