Files
UHD_Media_Tool/apps/web/app/components/subscriptions/actions.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

234 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Subscription } from "@umt/contracts";
import { Eraser, Grid3x3, MoreHorizontal, Pause, Pencil, Play, RotateCcw, Search, Trash2 } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import {
type SubscriptionAction,
useResetSubscription,
useSearchSubscription,
useSubscriptionAction,
useSubscriptionBatch,
} from "~/api/subscriptions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
// 订阅操作:立即搜索 / 暂停·恢复 / 编辑 / 重新订阅 / 删除 / 查看对比(总览卡片菜单与详情页共用)。
type Sub = Pick<Subscription, "id" | "status" | "mediaEntityId">;
const tracking = (s: Sub) => s.status === "active" || s.status === "pending";
export function useSubscriptionOps() {
const search = useSearchSubscription();
const action = useSubscriptionAction();
const batch = useSubscriptionBatch();
const reset = useResetSubscription();
const pending = search.isPending || action.isPending || batch.isPending || reset.isPending;
return {
pending,
search: (id: string) =>
search.mutate(id, {
onSuccess: (r) => toast.success(r.deduplicated ? "搜索已在进行" : "已开始搜索"),
onError: (e) => toast.error(errorMessage(e, "搜索失败")),
}),
act: (id: string, a: SubscriptionAction) =>
action.mutate(
{ id, action: a },
{
onSuccess: () => toast.success({ pause: "已暂停", resume: "已恢复", resubscribe: "已重新订阅" }[a]),
onError: (e) => toast.error(errorMessage(e)),
},
),
reset: (id: string) =>
reset.mutateAsync(id).then((r) => {
const kept = r.keptPlans.length ? `,${r.keptPlans.length} 个仍在下载的保留` : "";
toast.success(`已重置:释放 ${r.releasedPlans.length} 个计划${kept}`);
}),
remove: (ids: string[]) =>
batch.mutateAsync({ ids, action: "delete" }).then((r) => {
const bad = r.results.filter((x) => !x.ok);
if (bad.length) toast.error(`${bad.length} 个没有删除:${bad[0]?.error ?? ""}`);
else toast.success(ids.length > 1 ? `已删除 ${ids.length} 个订阅` : "订阅已删除");
}),
batch: (ids: string[], a: "search" | "pause" | "resume") =>
batch.mutate(
{ ids, action: a },
{
onSuccess: (r) => {
const ok = r.results.filter((x) => x.ok).length;
const label = { search: "开始搜索", pause: "暂停", resume: "恢复" }[a];
if (ok === ids.length) toast.success(`${ok} 个订阅已${label}`);
else toast.warning(`${ok}/${ids.length} 个已${label};${r.results.find((x) => !x.ok)?.error ?? ""}`);
},
onError: (e) => toast.error(errorMessage(e)),
},
),
};
}
function MenuItem({
icon,
children,
onClick,
danger,
disabled,
}: {
icon: ReactNode;
children: ReactNode;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className={cn(
"flex h-9 w-full cursor-pointer items-center gap-2 rounded-lg px-2.5 text-left text-sm text-foreground transition-colors duration-micro hover:bg-foreground/[0.06] disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:h-4 [&_svg]:w-4 [&_svg]:text-muted-foreground",
danger && "text-destructive [&_svg]:text-destructive",
)}
>
{icon}
{children}
</button>
);
}
/** 卡片 / 行的"更多"菜单 */
export function SubscriptionMenu({
sub,
onEdit,
showDetail = true,
trigger,
}: {
sub: Sub;
onEdit: () => void;
showDetail?: boolean;
trigger?: ReactNode;
}) {
const ops = useSubscriptionOps();
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const [confirm, setConfirm] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const run = (fn: () => void) => () => {
setOpen(false);
fn();
};
const ended = sub.status === "completed" || sub.status === "cancelled";
return (
<>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
{trigger ?? (
<Button
variant="ghost"
size="icon-sm"
aria-label="订阅操作"
onClick={(e) => e.stopPropagation()}
className="shrink-0"
>
<MoreHorizontal />
</Button>
)}
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu" onClick={(e) => e.stopPropagation()}>
{showDetail ? (
<MenuItem icon={<Grid3x3 />} onClick={run(() => navigate(consolePath("subscriptions", sub.id)))}>
查看对比
</MenuItem>
) : null}
{tracking(sub) && sub.mediaEntityId ? (
<MenuItem icon={<Search />} disabled={ops.pending} onClick={run(() => ops.search(sub.id))}>
立即搜索
</MenuItem>
) : null}
{sub.status !== "cancelled" ? (
<MenuItem icon={<Pencil />} onClick={run(onEdit)}>
编辑
</MenuItem>
) : null}
{tracking(sub) ? (
<MenuItem icon={<Pause />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "pause"))}>
暂停
</MenuItem>
) : sub.status === "paused" ? (
<MenuItem icon={<Play />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resume"))}>
恢复
</MenuItem>
) : null}
{!ended ? (
<MenuItem icon={<Eraser />} disabled={ops.pending} onClick={run(() => setConfirmReset(true))}>
重置
</MenuItem>
) : null}
{ended ? (
<MenuItem icon={<RotateCcw />} disabled={ops.pending} onClick={run(() => ops.act(sub.id, "resubscribe"))}>
重新订阅
</MenuItem>
) : null}
{sub.status !== "cancelled" ? (
<MenuItem icon={<Trash2 />} danger onClick={run(() => setConfirm(true))}>
删除
</MenuItem>
) : null}
</PopoverContent>
</Popover>
<DeleteSubscriptionsDialog open={confirm} onOpenChange={setConfirm} ids={[sub.id]} />
<ConfirmDialog
open={confirmReset}
onOpenChange={setConfirmReset}
title="重置这个订阅的进度?"
description="取消还没提交、或下载任务已不在的计划,清掉延后记录,然后重新评估。仍在下载的任务、已入库文件和总集数保留。"
confirmLabel="重置"
onConfirm={() =>
ops.reset(sub.id).catch((e) => {
toast.error(errorMessage(e));
throw e;
})
}
/>
</>
);
}
export function DeleteSubscriptionsDialog({
open,
onOpenChange,
ids,
onDone,
}: {
open: boolean;
onOpenChange: (o: boolean) => void;
ids: string[];
onDone?: () => void;
}) {
const ops = useSubscriptionOps();
return (
<ConfirmDialog
open={open}
onOpenChange={onOpenChange}
title={ids.length > 1 ? `删除 ${ids.length} 个订阅?` : "删除这个订阅?"}
description="不再搜索和追更。已入库文件、版本目标和下载记录保留,可在订阅历史里重新订阅。"
confirmLabel="删除"
destructive
onConfirm={() =>
ops
.remove(ids)
.then(() => onDone?.())
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
}
/>
);
}