Found in v0.2 visual QA (docs/plan/09-visual-qa.md): - actor strings (admin_session:<uuid>, user:<uuid>, system) -> 管理员/系统/API Key in policy revisions, subscriptions, inbox, trash, business tasks, organize detail - cache volume without key shows the downloader name instead of instance:<uuid> - download timeline/plan: task and organize statuses, plan purpose, priority and status reason localized; audit gets labels for capability/files/site/feed actions - organize record reason uses conflict detail (没有可入库的主视频) instead of blocked:no_main_video; slot/candidate reasons drop inflight:<uuid> - identity decision summary no longer shows no_viable_candidate - upstream test: 'Connection error.' classified as network, with a Chinese hint - new policy draft prefills key default (placeholder looked like a value) - dialog/sheet close sr-only text in Chinese Co-Authored-By: Claude Opus 5.5 <[email protected]>
771 lines
29 KiB
TypeScript
771 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 { actorText } from "~/lib/labels.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)} · ${actorText(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>
|
|
);
|
|
}
|