feat(web): subscriptions overview groups, coverage matrix view and v2 form (FE-5)
- Overview: kind tabs, five groups with server counts, card/list views, slot badges, progress (unknown total = "?"), filters in URL, batch actions - Detail /console/subscriptions/🆔 season tabs, episode x version-target matrix with cell sheet (manual fill, plan link), drag slot order, auto-fill switch and fill budget, refresh coverage - Create/edit dialog (basic/advanced): work picker via global search, season/range/total, sites, rule groups, include/exclude, templates, upgrade strategy, downloader/save path; 409 links to existing subscription Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
4deaaab6fc
commit
b7a7fd3958
13 files changed
+3241
-726
No files matched your search
@@ -0,0 +1,155 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user