Files
UHD_Media_Tool/apps/web/app/routes/console/subscriptions.tsx
T
rootandClaude Opus 5.5 909cd2ad49 feat(web): regroup console nav into 7 groups, split downloads/plans/tasks, add legacy redirects
- Sidebar: 概览/资源/订阅/下载/整理/Agent/系统, 18 items (+ 定时任务); single
  active item via findNavItem; 待处理 badge; collapsed rail groups; mobile drawer
  shares NAV_GROUPS.
- New pages: /downloads (queue + cache watermark header), /downloads/plans,
  /downloads/:id, /tasks (business tasks only) + /tasks/:id detail, /rules.
- Legacy links: ?tab= on tasks/subscriptions/sources redirect via clientLoader;
  /tasks/:downloadId falls back to /downloads/:id.
- List filters in URL query; settingsPath() helper for settings deep links.
- Copy per glossary (待处理, 收录策略, 引擎面板, 文件管理, 版本目标).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 02:46:47 +08:00

770 lines
29 KiB
TypeScript

import {
AcquisitionPriority,
type MediaEntity,
SeasonScope,
type Subscription,
SubscriptionCreate,
SubscriptionMediaKind,
SubscriptionMode,
type SubscriptionProgress,
type SubscriptionStatus,
} from "@umt/contracts";
import { CalendarClock, Check, ListChecks, Pause, Play, Plus, Search, XCircle } from "lucide-react";
import { useEffect, useId, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useEntitySearch } from "~/api/library.ts";
import {
type SubscriptionFilters,
useCreateSubscription,
useEntityTitle,
useSearchSubscription,
useSetEpisodeTotal,
useSubscriptionProgress,
useSubscriptions,
useUpdateSubscription,
} from "~/api/subscriptions.ts";
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import {
MEDIA_KIND,
opts,
PRIORITY,
SEASON_SCOPE,
SUB_MODE,
SUB_ORIGIN,
SUB_STATUS,
} from "~/components/library/labels.ts";
import { Chip, relTime, Tone } from "~/components/library/parts.tsx";
import { SlotsPanel } from "~/components/library/subscriptions-slots.tsx";
import { Button, buttonVariants } from "~/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "~/components/ui/dialog.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatDateTime } from "~/lib/utils.ts";
import { legacyRedirectLoader } from "./redirects.ts";
// 订阅:单次/持续、仅本季/接续 + 各作品的版本目标(REQ §6.1、§6.4)。收录策略在 /console/rules。
export const meta = () => [{ title: "订阅 · UHD 媒体自动化控制台" }];
// 旧的 ?tab=policies 跳到收录策略
export const clientLoader = legacyRedirectLoader;
const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { movie: "电影", series: "剧集", variety: "综艺" };
export default function SubscriptionsPage() {
const [creatingSub, setCreatingSub] = useState(false);
return (
<>
<PageHeader
title="订阅"
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("rules")}>
<ListChecks />
收录策略
</Link>
</Button>
<Button onClick={() => setCreatingSub(true)}>
<Plus />
新建订阅
</Button>
</>
}
/>
<SubscriptionsTab />
{creatingSub ? <CreateSubscriptionDialog onClose={() => setCreatingSub(false)} /> : null}
</>
);
}
// ── 订阅列表 ──
function useDebounced(v: string, ms = 300) {
const [d, setD] = useState(v);
useEffect(() => {
const t = setTimeout(() => setD(v), ms);
return () => clearTimeout(t);
}, [v, ms]);
return d;
}
function EntityTitle({ sub }: { sub: Subscription }) {
// 列表响应已带 mediaEntityTitle;没有时(旧数据/详情)再按 ID 取
const q = useEntityTitle(sub.mediaEntityTitle ? null : sub.mediaEntityId);
if (sub.mediaEntityId && sub.mediaEntityTitle)
return <span className="min-w-0 break-all text-foreground">{sub.mediaEntityTitle}</span>;
if (!sub.mediaEntityId)
return (
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
<span className="break-all text-foreground">{sub.titleQuery ?? "—"}</span>
<Chip>尚未建档</Chip>
</span>
);
if (q.isPending) return <span className="text-muted-foreground">加载中…</span>;
if (q.isError)
return <span className="font-mono text-meta text-muted-foreground">{sub.mediaEntityId.slice(0, 8)}(无法读取)</span>;
return (
<span className="min-w-0 break-all text-foreground">
{q.data.title}
{q.data.year ? <span className="text-muted-foreground"> ({q.data.year})</span> : null}
</span>
);
}
function SubscriptionsTab() {
const [status, setStatus] = useState("");
const [kind, setKind] = useState("");
const [search, setSearch] = useState("");
const dq = useDebounced(search.trim());
const filters: SubscriptionFilters = {
status: (status || undefined) as SubscriptionFilters["status"],
mediaKind: (kind || undefined) as SubscriptionFilters["mediaKind"],
q: dq || undefined,
};
const filtered = !!status || !!kind || !!search;
const q = useSubscriptions(filters);
const rows = useMemo(() => q.data?.pages.flatMap((p) => p.items), [q.data]);
const [openId, setOpenId] = useState<string | null>(null);
const open = rows?.find((r) => r.id === openId) ?? null;
const columns: DataColumn<Subscription>[] = [
{ key: "title", header: "作品", cell: (r) => <EntityTitle sub={r} />, className: "min-w-40" },
{
key: "kind",
header: "类型",
cell: (r) => SUB_KIND_LABEL[r.mediaKind],
hideBelow: "sm",
className: "whitespace-nowrap",
},
{
key: "mode",
header: "追踪方式",
cell: (r) => (
<span className="whitespace-nowrap">
{SUB_MODE[r.mode]}
{r.mediaKind !== "movie" ? (
<span className="text-muted-foreground"> · {SEASON_SCOPE[r.seasonScope]}</span>
) : null}
</span>
),
hideBelow: "md",
},
{
key: "priority",
header: "优先级",
cell: (r) => PRIORITY[r.priority],
hideBelow: "lg",
className: "whitespace-nowrap",
},
{ key: "status", header: "状态", cell: (r) => <Tone v={SUB_STATUS[r.status]} /> },
{
key: "checked",
header: "最近检查",
cell: (r) => (
<span className="whitespace-nowrap text-muted-foreground tabular-nums">{relTime(r.lastCheckedAt)}</span>
),
hideBelow: "md",
align: "right",
},
];
return (
<>
<FilterBar
search={{ value: search, onChange: setSearch, placeholder: "按作品标题 / 别名 / 检索词搜索" }}
onReset={
filtered
? () => {
setStatus("");
setKind("");
setSearch("");
}
: undefined
}
>
<FilterSelect label="状态" value={status} onChange={setStatus} options={opts(SUB_STATUS)} />
<FilterSelect label="类型" value={kind} onChange={setKind} options={opts(SUB_KIND_LABEL)} />
</FilterBar>
<DataTable
columns={columns}
rows={rows}
rowKey={(r) => r.id}
loading={q.isPending}
error={q.error}
onRetry={() => q.refetch()}
onRowClick={(r) => setOpenId(r.id)}
selectedKey={openId}
hasMore={q.hasNextPage}
onLoadMore={() => q.fetchNextPage()}
loadingMore={q.isFetchingNextPage}
empty={{
icon: CalendarClock,
title: filtered ? "没有符合条件的订阅" : "还没有订阅",
description: filtered ? undefined : "订阅的作品会按版本目标补齐和升级。点「新建订阅」添加。",
}}
/>
<SubscriptionSheet sub={open} onClose={() => setOpenId(null)} />
</>
);
}
// ── 订阅详情:修改 + 收录目标 ──
function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose: () => void }) {
const update = useUpdateSubscription();
const search = useSearchSubscription();
const runSearch = () =>
sub &&
search.mutate(sub.id, {
onSuccess: () => toast.success("已在启用的站点开始搜索"),
onError: (e) => toast.error(errorMessage(e)),
});
const title = useEntityTitle(sub?.mediaEntityId);
const setStatus = (status: SubscriptionStatus, done: string) =>
sub
? update
.mutateAsync({ id: sub.id, status })
.then(() => toast.success(done))
.catch((e) => {
toast.error(errorMessage(e));
throw e;
})
: Promise.resolve();
return (
<DetailSheet
open={!!sub}
onOpenChange={(o) => !o && onClose()}
width="lg"
title={sub ? (sub.mediaEntityId ? (title.data?.title ?? "订阅") : (sub.titleQuery ?? "订阅")) : ""}
description={sub ? `${SUB_KIND_LABEL[sub.mediaKind]} · 来源:${SUB_ORIGIN[sub.origin] ?? sub.origin}` : null}
footer={
sub && sub.status !== "cancelled" && sub.status !== "completed" ? (
<>
{sub.mediaEntityId ? (
<Button variant="outline" onClick={runSearch} disabled={search.isPending}>
<Search />
{search.isPending ? "搜索中…" : "搜索资源"}
</Button>
) : null}
{sub.status === "active" || sub.status === "pending" ? (
<Button variant="outline" onClick={() => setStatus("paused", "已暂停")} disabled={update.isPending}>
<Pause />
暂停
</Button>
) : (
<Button variant="outline" onClick={() => setStatus("active", "已恢复")} disabled={update.isPending}>
<Play />
恢复
</Button>
)}
<ConfirmDialog
trigger={
<Button variant="outline" className="hover:border-destructive/40 hover:text-destructive">
<XCircle />
取消订阅
</Button>
}
title="取消这个订阅?"
description="不再追踪后续内容。已入库文件、版本目标和下载记录保留。"
confirmLabel="取消订阅"
destructive
onConfirm={() => setStatus("cancelled", "订阅已取消")}
/>
</>
) : null
}
>
{sub ? (
<>
<DetailSection title="概况">
<KvList
items={[
{ label: "状态", value: <Tone v={SUB_STATUS[sub.status]} /> },
{
label: "作品",
value: sub.mediaEntityId ? (
<Link className="underline-offset-4 hover:underline" to={consolePath("library", sub.mediaEntityId)}>
查看作品
</Link>
) : (
`按标题追踪:${sub.titleQuery ?? "—"}(尚未建档)`
),
},
{ label: "状态说明", value: sub.statusReason },
{ label: "最近检查", value: formatDateTime(sub.lastCheckedAt) },
{
label: "主动搜索",
value: sub.lastSearchedAt
? `上次 ${formatDateTime(sub.lastSearchedAt)}${sub.nextSearchAt ? ` · 下次 ${formatDateTime(sub.nextSearchAt)}` : " · 周期搜索已关"}`
: "尚未搜索",
},
{ label: "创建", value: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` },
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
]}
/>
</DetailSection>
{sub.mediaEntityId && sub.mediaKind !== "movie" ? (
<DetailSection title="追更进度">
<ProgressPanel subId={sub.id} readOnly={sub.status === "cancelled"} />
</DetailSection>
) : null}
<DetailSection title="追踪方式">
<SubscriptionEditForm key={`${sub.id}-${sub.updatedAt}`} sub={sub} />
</DetailSection>
<DetailSection title="版本目标">
{sub.mediaEntityId ? (
<SlotsPanel mediaEntityId={sub.mediaEntityId} subscriptionId={sub.id} />
) : (
<p className="text-caption text-muted-foreground">作品建档后才能设置版本目标。</p>
)}
</DetailSection>
</>
) : null}
</DetailSheet>
);
}
const UNIT_STATE: Record<
SubscriptionProgress["seasons"][number]["units"][number]["state"],
{ label: string; cls: string }
> = {
covered: { label: "已入库", cls: "border-success/30 bg-success/10 text-foreground" },
inflight: { label: "下载中", cls: "border-foreground/30 bg-foreground/[0.06] text-foreground" },
missing: { label: "缺失", cls: "border-border text-muted-foreground" },
suspected_missing: { label: "疑似缺集", cls: "border-warning/50 bg-warning/10 text-foreground" },
out_of_range: { label: "不在范围", cls: "border-dashed border-border text-muted-foreground" },
};
/** 每季总集数(来源/锁定)+ 每集状态;可手动设置总集数(不锁定只升) */
function ProgressPanel({ subId, readOnly }: { subId: string; readOnly: boolean }) {
const id = useId();
const progress = useSubscriptionProgress(subId);
const setTotal = useSetEpisodeTotal();
const [season, setSeason] = useState("1");
const [total, setTotalValue] = useState("");
const [locked, setLocked] = useState(false);
if (progress.isPending) return <p className="text-caption text-muted-foreground">读取中…</p>;
if (progress.isError) return <p className="text-caption text-destructive">{errorMessage(progress.error)}</p>;
const p = progress.data;
const save = () =>
setTotal.mutate(
{ id: subId, seasonNumber: Number(season), total: Number(total), locked },
{
onSuccess: () => {
toast.success(locked ? "总集数已锁定" : "总集数已更新");
setTotalValue("");
},
onError: (e) => toast.error(errorMessage(e)),
},
);
return (
<div className="space-y-3">
{!p.completable ? <p className="text-meta text-muted-foreground">总集数未知,订阅不会自动结束。</p> : null}
{p.seasons.length === 0 ? <p className="text-caption text-muted-foreground">没有季信息。</p> : null}
{p.seasons.map((s) => (
<div key={s.seasonNumber} className="space-y-1.5">
<p className="text-sm font-medium text-foreground">
第 {s.seasonNumber} 季 · {s.totalKnown ? `共 ${s.episodeTotal} 集` : "总集数未知"}
<span className="text-meta text-muted-foreground">
{s.totalSource ? `(${s.totalSource}${s.totalLocked ? ",已锁定" : ""})` : ""}
{s.complete ? " · 已齐" : ""}
</span>
</p>
<div className="flex flex-wrap gap-1">
{s.units.map((u) => (
<span
key={u.episode}
title={`E${u.episode} ${UNIT_STATE[u.state].label}${u.quality ? ` · ${u.quality.resolution ?? ""} ${u.quality.source ?? ""}${u.quality.pri !== null ? ` pri ${u.quality.pri}` : ""}` : ""}`}
className={cn("rounded-md border px-1.5 py-0.5 font-mono text-meta", UNIT_STATE[u.state].cls)}
>
E{String(u.episode).padStart(2, "0")}
</span>
))}
</div>
{s.suspectedMissing.length ? (
<p className="text-meta text-muted-foreground">
疑似缺集:{s.suspectedMissing.map((e) => `E${e}`).join("、")}
</p>
) : null}
</div>
))}
{p.slots.some((x) => x.inflightPlans.length > 1 || x.deferred > 0) ? (
<ul className="space-y-0.5 text-meta text-muted-foreground">
{p.slots.map((x) => (
<li key={x.slotId}>
{x.name}:在途 {x.inflightPlans.length} 个计划,延后 {x.deferred} 个资源
</li>
))}
</ul>
) : null}
{readOnly ? null : (
<div className="flex flex-wrap items-end gap-2">
<FormField label="季" htmlFor={`${id}-s`}>
<Input
id={`${id}-s`}
className="w-16"
inputMode="numeric"
value={season}
onChange={(e) => setSeason(e.target.value)}
/>
</FormField>
<FormField label="总集数" htmlFor={`${id}-t`}>
<Input
id={`${id}-t`}
className="w-20"
inputMode="numeric"
value={total}
onChange={(e) => setTotalValue(e.target.value)}
/>
</FormField>
<label className="flex h-9 items-center gap-1.5 text-sm text-foreground">
<input type="checkbox" checked={locked} onChange={(e) => setLocked(e.target.checked)} />
锁定
</label>
<Button
variant="outline"
size="sm"
onClick={save}
disabled={setTotal.isPending || !/^\d+$/.test(total) || Number(total) < 1 || !/^\d+$/.test(season)}
>
设置总集数
</Button>
</div>
)}
</div>
);
}
function SubscriptionEditForm({ sub }: { sub: Subscription }) {
const id = useId();
const update = useUpdateSubscription();
const [mode, setMode] = useState<SubscriptionMode>(sub.mode);
const [scope, setScope] = useState<SeasonScope>(sub.seasonScope);
const [priority, setPriority] = useState<AcquisitionPriority>(sub.priority);
const dirty = mode !== sub.mode || scope !== sub.seasonScope || priority !== sub.priority;
const disabled = sub.status === "cancelled" || sub.status === "completed";
const save = () =>
update.mutate(
{ id: sub.id, mode, seasonScope: scope, priority },
{ onSuccess: () => toast.success("追踪方式已更新"), onError: (e) => toast.error(errorMessage(e)) },
);
return (
<div className="space-y-3">
<div className="grid gap-3 sm:grid-cols-3">
<FormField label="方式" htmlFor={`${id}-mode`}>
<EnumSelect
id={`${id}-mode`}
value={mode}
onChange={setMode}
options={SubscriptionMode.options}
labels={SUB_MODE}
disabled={disabled}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={sub.mediaKind === "movie" ? "电影不适用" : undefined}>
<EnumSelect
id={`${id}-scope`}
value={scope}
onChange={setScope}
options={SeasonScope.options}
labels={SEASON_SCOPE}
disabled={disabled || sub.mediaKind === "movie"}
/>
</FormField>
<FormField label="优先级" htmlFor={`${id}-prio`}>
<EnumSelect
id={`${id}-prio`}
value={priority}
onChange={setPriority}
options={AcquisitionPriority.options}
labels={PRIORITY}
disabled={disabled}
/>
</FormField>
</div>
{mode === "continuous" && scope === "following_seasons" && sub.mediaKind !== "movie" ? (
<p className="text-meta text-muted-foreground">特别篇、衍生节目和同名重启不算作下一季。</p>
) : null}
<div className="flex justify-end">
<Button variant="outline" size="sm" onClick={save} disabled={!dirty || update.isPending}>
<Check />
{update.isPending ? "保存中…" : "保存"}
</Button>
</div>
</div>
);
}
function EnumSelect<T extends string>({
id,
value,
onChange,
options,
labels,
disabled,
}: {
id: string;
value: T;
onChange: (v: T) => void;
options: readonly T[];
labels: Record<T, string>;
disabled?: boolean;
}) {
return (
<Select value={value} onValueChange={(v) => onChange(v as T)} disabled={disabled}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{options.map((o) => (
<SelectItem key={o} value={o}>
{labels[o]}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// ── 新建订阅 ──
const toSubKind = (k: MediaEntity["kind"]): SubscriptionMediaKind =>
k === "movie" ? "movie" : k === "variety" ? "variety" : "series";
function CreateSubscriptionDialog({ onClose }: { onClose: () => void }) {
const id = useId();
const create = useCreateSubscription();
const [byTitle, setByTitle] = useState(false);
const [term, setTerm] = useState("");
const [entity, setEntity] = useState<MediaEntity | null>(null);
const [titleQuery, setTitleQuery] = useState("");
const [mediaKind, setMediaKind] = useState<SubscriptionMediaKind>("series");
const [mode, setMode] = useState<SubscriptionMode>("continuous");
const [scope, setScope] = useState<SeasonScope>("following_seasons");
const [priority, setPriority] = useState<AcquisitionPriority>("tracking");
const [errors, setErrors] = useState<Record<string, string>>({});
const search = useEntitySearch(term, { enabled: !byTitle && !entity });
const pick = (e: MediaEntity) => {
setEntity(e);
const k = toSubKind(e.kind);
setMediaKind(k);
// REQ §6.5:用户主动添加的剧集/综艺默认持续追踪并接续后续季;电影默认单次
setMode(k === "movie" ? "once" : "continuous");
setScope("following_seasons");
};
const submit = () => {
const body = {
mediaEntityId: byTitle ? undefined : entity?.id,
titleQuery: byTitle ? titleQuery.trim() || undefined : undefined,
mediaKind,
mode,
seasonScope: mediaKind === "movie" ? "current_season" : scope,
priority,
origin: "user" as const,
};
const v = SubscriptionCreate.safeParse(body);
if (!v.success) {
const errs = zodFieldErrors(v.error.issues);
if (errs[""]) errs.target = byTitle ? "填写标题" : "选择作品";
setErrors(errs);
return;
}
setErrors({});
create.mutate(v.data, {
onSuccess: () => {
toast.success("订阅已创建");
onClose();
},
onError: (e) => toast.error(errorMessage(e)),
});
};
return (
<Dialog open onOpenChange={(o) => !o && !create.isPending && onClose()}>
<DialogContent className="max-h-[90dvh] overflow-y-auto sm:max-w-xl">
<DialogHeader>
<DialogTitle>新建订阅</DialogTitle>
<DialogDescription>选择库里的作品,或按标题追踪,建档后自动关联。</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-3">
<fieldset className="flex gap-1" aria-label="订阅对象">
{[
[false, "选择作品"],
[true, "按标题追踪"],
].map(([v, l]) => (
<button
key={String(l)}
type="button"
aria-pressed={byTitle === v}
onClick={() => {
setByTitle(v as boolean);
setErrors({});
}}
className={cn(
"h-8 cursor-pointer rounded-full border px-3 text-sm font-medium transition-colors duration-micro",
byTitle === v
? "pill-selected text-foreground"
: "border-transparent text-muted-foreground hover:bg-foreground/[0.06]",
)}
>
{l}
</button>
))}
</fieldset>
{byTitle ? (
<FormField
label="标题"
htmlFor={`${id}-tq`}
required
error={errors.target ?? errors.titleQuery}
hint="原名、译名或站点标题都可以"
>
<Input
id={`${id}-tq`}
value={titleQuery}
aria-invalid={!!errors.target}
onChange={(e) => setTitleQuery(e.target.value)}
/>
</FormField>
) : entity ? (
<div className="flex items-center justify-between gap-2 rounded-lg border border-border px-3 py-2">
<div className="min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{entity.title}
{entity.year ? <span className="text-muted-foreground"> ({entity.year})</span> : null}
</p>
<p className="text-meta text-muted-foreground">{MEDIA_KIND[entity.kind]}</p>
</div>
<Button variant="ghost" size="sm" onClick={() => setEntity(null)}>
更换
</Button>
</div>
) : (
<FormField label="作品" htmlFor={`${id}-q`} required error={errors.target}>
<div className="relative">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
id={`${id}-q`}
className="pl-9"
value={term}
aria-invalid={!!errors.target}
placeholder="搜索标题 / 原名"
onChange={(e) => setTerm(e.target.value)}
/>
</div>
<div className="max-h-52 overflow-y-auto rounded-lg border border-border/70">
{search.isPending ? (
<p className="px-3 py-2 text-caption text-muted-foreground">搜索中…</p>
) : search.isError ? (
<p className="px-3 py-2 text-caption text-destructive">{errorMessage(search.error)}</p>
) : (search.data?.items ?? []).filter((e) => e.status !== "merged").length === 0 ? (
<p className="px-3 py-2 text-caption text-muted-foreground">没有匹配的作品。可改用"按标题追踪"。</p>
) : (
<ul className="divide-y divide-border/60">
{(search.data?.items ?? [])
.filter((e) => e.status !== "merged")
.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => pick(e)}
className="flex w-full cursor-pointer items-center justify-between gap-2 px-3 py-2 text-left hover:bg-foreground/[0.04]"
>
<span className="min-w-0 truncate text-sm text-foreground">
{e.title}
{e.year ? <span className="text-muted-foreground"> ({e.year})</span> : null}
</span>
<span className="shrink-0 text-meta text-muted-foreground">{MEDIA_KIND[e.kind]}</span>
</button>
</li>
))}
</ul>
)}
</div>
</FormField>
)}
<div className="grid gap-3 sm:grid-cols-2">
<FormField label="类型" htmlFor={`${id}-kind`} error={errors.mediaKind}>
<EnumSelect
id={`${id}-kind`}
value={mediaKind}
onChange={(k) => {
setMediaKind(k);
if (k === "movie") setMode("once");
}}
options={SubscriptionMediaKind.options}
labels={SUB_KIND_LABEL}
disabled={!byTitle && !!entity}
/>
</FormField>
<FormField label="方式" htmlFor={`${id}-mode`}>
<EnumSelect
id={`${id}-mode`}
value={mode}
onChange={setMode}
options={SubscriptionMode.options}
labels={SUB_MODE}
/>
</FormField>
<FormField label="季范围" htmlFor={`${id}-scope`} hint={mediaKind === "movie" ? "电影不适用" : undefined}>
<EnumSelect
id={`${id}-scope`}
value={mediaKind === "movie" ? "current_season" : scope}
onChange={setScope}
options={SeasonScope.options}
labels={SEASON_SCOPE}
disabled={mediaKind === "movie"}
/>
</FormField>
<FormField label="优先级" htmlFor={`${id}-prio`} hint="只影响资源分配,不越过磁盘阈值与权限">
<EnumSelect
id={`${id}-prio`}
value={priority}
onChange={setPriority}
options={AcquisitionPriority.options}
labels={PRIORITY}
/>
</FormField>
</div>
</div>
<DialogFooter>
<button
type="button"
className={buttonVariants({ variant: "outline" })}
onClick={onClose}
disabled={create.isPending}
>
取消
</button>
<Button onClick={submit} disabled={create.isPending}>
{create.isPending ? "创建中…" : "创建订阅"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}