Files
UHD_Media_Tool/apps/web/app/components/subscriptions/actions.tsx
T
rootandClaude Opus 5.5 550ac285f4 feat(copy): 处理状态的「入库」改为「整理」,术语表与 check:copy 同步
真正入库取决于媒体服务器有没有扫到,本工具只负责整理(元数据、NFO 与图片、按规则归到目录)。
界面与面向用户的服务端文案里「已入库 / 未入库 / 部分入库 / 入库失败 / 入库中 / 入库后 / 原地入库…」
改为「已整理 / 未整理 / 部分整理 / 整理失败 / 整理中 / 整理后 / 原地整理…」;文件管理快速筛选「已整理」、
目录副行「n 个已整理 / 未整理」、季卡片「已整理 x/y」。代码标识符不改,「Emby 已有」这类媒体服务器状态保留。
glossary 加「整理」口径条目;check:copy 拦处理状态语境的「入库」搭配,其它线待替换的文件暂列 TERM_PENDING。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 17:54:28 +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;
})
}
/>
);
}