- Agent page uses contracts (AgentRun/AgentRunDetail/AgentGoalReport) instead of a local mirror; ?mock=1 stays as an explicit demo switch; run snapshot refreshes on agent.run_status; stream auto-follow only while the user stays near the bottom (abort stays reachable) - settings: Pi provider select incl. fake, new Agent runtime card (budgets, fakeScript) - download trace uses /tasks?subjectId= and /organization-records?downloadTaskId= instead of time-window engine run matching and path matching; new business-task tab with cancel/retry - workbench download dialog posts /acquisition-plans and shows per-slot rejection reasons - preference tag revision history; subscription search by title/kind; identity/alias/subscription lists show entity titles; slot cards show current asset summary; policy preview sample titles - DecisionSummary / IdentityRevisionList / ReleaseIdentityResult / TaskAccepted / PolicyPreviewSummary / OrganizePlanArtifactContent taken from contracts - docs: SSE for console (cookie) vs GET /events?after=&limit= JSON replay for API keys Co-Authored-By: Claude Opus 5.5 <[email protected]>
657 lines
24 KiB
TypeScript
657 lines
24 KiB
TypeScript
import {
|
|
AcquisitionPriority,
|
|
type MediaEntity,
|
|
SeasonScope,
|
|
type Subscription,
|
|
SubscriptionCreate,
|
|
SubscriptionMediaKind,
|
|
SubscriptionMode,
|
|
type SubscriptionStatus,
|
|
} from "@umt/contracts";
|
|
import { CalendarClock, Check, FilePlus2, Pause, Play, Plus, Search, XCircle } from "lucide-react";
|
|
import { useEffect, useId, useMemo, useState } from "react";
|
|
import { Link, useSearchParams } from "react-router";
|
|
import { toast } from "sonner";
|
|
import { useEntitySearch } from "~/api/library.ts";
|
|
import {
|
|
type SubscriptionFilters,
|
|
useCreateSubscription,
|
|
useEntityTitle,
|
|
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 { PolicyWorkspace } from "~/components/library/subscriptions-policy.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 { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
|
import { errorMessage } from "~/lib/api.ts";
|
|
import { cn, formatDateTime } from "~/lib/utils.ts";
|
|
|
|
// 订阅与收录规则:订阅(单次/持续、仅本季/接续)+ 各作品的收录目标 Slot + 收录策略版本(REQ §6.1、§6.4、§6.5)
|
|
|
|
const SUB_KIND_LABEL: Record<SubscriptionMediaKind, string> = { movie: "电影", series: "剧集", variety: "综艺" };
|
|
|
|
export default function SubscriptionsPage() {
|
|
const [params, setParams] = useSearchParams();
|
|
const tab = params.get("tab") === "policies" ? "policies" : "subscriptions";
|
|
const [creatingSub, setCreatingSub] = useState(false);
|
|
const [creatingPolicy, setCreatingPolicy] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
<PageHeader
|
|
title="订阅与收录规则"
|
|
description="订阅决定追什么,收录目标决定要哪种版本,策略决定默认怎么做;启用即授权范围内的持续操作。"
|
|
actions={
|
|
tab === "subscriptions" ? (
|
|
<Button onClick={() => setCreatingSub(true)}>
|
|
<Plus />
|
|
新建订阅
|
|
</Button>
|
|
) : (
|
|
<Button onClick={() => setCreatingPolicy(true)}>
|
|
<FilePlus2 />
|
|
新建策略草案
|
|
</Button>
|
|
)
|
|
}
|
|
/>
|
|
<Tabs
|
|
value={tab}
|
|
onValueChange={(v) =>
|
|
setParams(
|
|
(p) => {
|
|
if (v === "policies") p.set("tab", "policies");
|
|
else p.delete("tab");
|
|
return p;
|
|
},
|
|
{ replace: true },
|
|
)
|
|
}
|
|
>
|
|
<TabsList aria-label="订阅与收录规则">
|
|
<TabsTrigger value="subscriptions">订阅</TabsTrigger>
|
|
<TabsTrigger value="policies">收录策略</TabsTrigger>
|
|
</TabsList>
|
|
<TabsContent value="subscriptions">
|
|
<SubscriptionsTab />
|
|
</TabsContent>
|
|
<TabsContent value="policies">
|
|
<PolicyWorkspace creating={creatingPolicy} onCreatingChange={setCreatingPolicy} />
|
|
</TabsContent>
|
|
</Tabs>
|
|
{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: "订阅一部作品后,系统按收录目标持续补齐与升级;也可以只填标题,等作品建档后自动关联。",
|
|
}}
|
|
/>
|
|
<SubscriptionSheet sub={open} onClose={() => setOpenId(null)} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
// ── 订阅详情:修改 + 收录目标 ──
|
|
|
|
function SubscriptionSheet({ sub, onClose }: { sub: Subscription | null; onClose: () => void }) {
|
|
const update = useUpdateSubscription();
|
|
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.status === "active" ? (
|
|
<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: `${formatDateTime(sub.createdAt)} · ${sub.createdBy ?? "—"}` },
|
|
{ label: "策略版本", value: sub.policyRevisionId ?? "按当前已发布策略", mono: !!sub.policyRevisionId },
|
|
]}
|
|
/>
|
|
</DetailSection>
|
|
<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>
|
|
);
|
|
}
|
|
|
|
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" ? "电影不适用" : "随时可改"}>
|
|
<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>
|
|
);
|
|
}
|