Files
UHD_Media_Tool/apps/web/app/components/subscriptions/actions.tsx
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +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;
})
}
/>
);
}