Files
UHD_Media_Tool/apps/web/app/components/sites/site-add-dialog.tsx
T
rootandClaude Opus 5.5 b8876542b8 feat(sites): 内置公开 BT 站 Nyaa / 蜜柑 / AniBT / AnimeGarden / 萌番组
- 新专用解析器(spiders/public.ts):RSS / Torznab / JSON API,搜索 + 最新列表 + 按资源 ID 取种子或磁力
- 手写定义(definitions/public.ts,public: true),并入内置定义表;定义视图按 public 标为 public_bt
- 添加站点:公开 BT 站可从内置站一键添加(专用适配器、默认定时抓取、默认不进搜索集合),手填 RSS 保留
- 样例取自 2026-10-02 公开接口,截前两条

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 21:19:19 +08:00

607 lines
24 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 {
cursorPage,
FeedSource,
SITE_KINDS,
type SiteDefinitionView,
type SiteHealthResult,
type SiteKind,
type SiteListItem,
type SiteLoginResult,
} from "@umt/contracts";
import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck } from "lucide-react";
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useFetchFeedNow } from "~/api/feeds.ts";
import { apiParsed } from "~/api/parse.ts";
import {
useCreateSite,
useHealthCheck,
usePutCredentials,
useRefreshLogin,
useSiteDefinitions,
useUpdateSite,
} from "~/api/sites.ts";
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FormField } from "~/components/console/form-field.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } 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 { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential } from "./credential-fields.tsx";
import { LoginResultView } from "./site-dialogs.tsx";
import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts";
// 添加站点向导(site-center §4.3 + IA-2 站点类型):
// ⓪ 选类型 → PT 站:① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康);
// 公开 BT 站 / 自定义 RSS 源:① 名称 / 地址 / RSS 地址 → 保存后立即抓一次,进站点的 RSS Tab。
// 旧的"未适配站点(自定义 RSS 站点)"入口并入「自定义 RSS 源」类型。
type Step = 0 | 1 | 2 | 3;
const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const;
const FEED_STEPS = ["选择类型", "填写地址"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
const FeedPage = cursorPage(FeedSource);
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
function isHttpUrl(v: string): boolean {
try {
return /^https?:$/.test(new URL(v.trim()).protocol);
} catch {
return false;
}
}
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie");
}
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const startKind = initialKind && SITE_KINDS[initialKind]?.implemented ? initialKind : null;
const [kind, setKind] = useState<SiteKind | null>(startKind);
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
const [rssUrl, setRssUrl] = useState("");
const [q, setQ] = useState("");
const [defKey, setDefKey] = useState<string | null>(null);
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
const [cred, setCred] = useState<CredentialDraft>(emptyCredential("cookie"));
const [errors, setErrors] = useState<Record<string, string>>({});
const [created, setCreated] = useState<SiteListItem | null>(null);
const [login, setLogin] = useState<SiteLoginResult | null>(null);
const [health, setHealth] = useState<SiteHealthResult | null>(null);
const [phase, setPhase] = useState<string | null>(null);
const create = useCreateSite();
const update = useUpdateSite();
const put = usePutCredentials();
const refresh = useRefreshLogin();
const check = useHealthCheck();
const fetchFeed = useFetchFeedNow();
const feedKind = kind !== null && kind !== "pt";
const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS;
const chooseKind = (k: SiteKind) => {
setKind(k);
setErrors({});
if (k !== "pt") setForm({ name: "", baseUrl: "", key: "" });
setStep(1);
};
/** 公开 BT 站 / 自定义 RSS 源:建站点 + RSS(服务端一个事务),立即抓一次,进 RSS Tab */
const saveFeedSite = async () => {
const fe: Record<string, string> = {};
const url = rssUrl.trim();
const base = form.baseUrl.trim();
if (!form.name.trim()) fe.name = "填写名称";
if (kind === "rss_feed" && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
if (kind === "public_bt" && url && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
if (kind === "public_bt" && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
if (kind === "rss_feed" && base && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
setErrors(fe);
if (Object.keys(fe).length || !kind) return;
try {
setPhase("保存中…");
const site = await create.mutateAsync({
kind,
adapter: "generic_rss",
name: form.name.trim(),
baseUrl: base || new URL(url).origin,
...(url ? { rssUrl: url } : {}),
});
if (url) {
const feeds = await apiParsed(FeedPage, "/api/v1/feed-sources", { params: { siteId: site.id } });
for (const f of feeds.items) fetchFeed.mutate(f.id);
}
toast.success(`已添加「${site.name}」`);
onClose();
navigate(`${consolePath("sites", site.id)}?tab=rss`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const items = defs.data?.items ?? [];
const def = items.find((d) => d.key === defKey);
const kinds: LoginKind[] = supportedKinds(def);
const busy = phase !== null;
const groups = useMemo(() => {
const needle = q.trim().toLowerCase();
const hit = (d: SiteDefinitionView) =>
!needle || d.name.toLowerCase().includes(needle) || d.key.includes(needle) || d.baseUrl.includes(needle);
return GROUP_ORDER.map((a) => ({
adapter: a,
items: items
.filter((d) => d.kind === "pt" && d.adapter === a && hit(d))
.sort((x, y) => x.name.localeCompare(y.name, "zh-CN")),
})).filter((g) => g.items.length);
}, [items, q]);
const publicDefs = items.filter((d) => d.kind === "public_bt");
/** 内置公开 BT 站:一步建好(专用适配器、定时抓最新),进站点页 */
const addPublic = async (d: SiteDefinitionView) => {
try {
setPhase("保存中…");
const site = await create.mutateAsync({
kind: "public_bt",
key: d.key,
adapter: d.adapter,
name: d.name,
baseUrl: d.baseUrl,
});
toast.success(`已添加「${site.name}」`);
onClose();
navigate(consolePath("sites", site.id));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const pick = (key: string) => {
const d = items.find((x) => x.key === key);
setDefKey(key);
setForm(d ? { name: d.name, baseUrl: d.baseUrl, key: d.key } : { name: "", baseUrl: "", key: "" });
setCred(emptyCredential(defaultKind(d ? supportedKinds(d) : [])));
setErrors({});
setStep(2);
};
const kindBody = (
<ul className="grid gap-2 sm:grid-cols-2">
{KIND_ORDER.map((k) => {
const spec = SITE_KINDS[k];
const Icon = KIND_ICON[k];
return (
<li key={k}>
<button
type="button"
disabled={!spec.implemented}
onClick={() => chooseKind(k)}
className="flex h-full w-full min-w-0 items-start gap-3 rounded-lg border border-border/70 px-3 py-3 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0">
<span className="flex items-center gap-2 text-caption font-medium text-foreground">
{spec.label}
{!spec.implemented ? <StatusBadge className="shrink-0">未支持</StatusBadge> : null}
</span>
<span className="mt-0.5 block text-meta text-muted-foreground">{spec.description}</span>
</span>
</button>
</li>
);
})}
</ul>
);
const feedBody = (
<form
id="site-add-feed"
noValidate
onSubmit={(e) => {
e.preventDefault();
void saveFeedSite();
}}
className="min-w-0"
>
{kind === "public_bt" && publicDefs.length ? (
<div className="mb-4">
<p className="mb-2 text-caption font-medium text-foreground">内置站点</p>
<ul className="grid gap-2 sm:grid-cols-2">
{publicDefs.map((d) => (
<li key={d.key}>
<button
type="button"
disabled={busy || !!d.siteId}
onClick={() => void addPublic(d)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
</span>
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
</button>
</li>
))}
</ul>
<p className="mt-3 mb-2 text-meta text-muted-foreground">
内置站点支持搜索和定时抓取最新资源,不需要账号。其他公开站可以在下面手动填 RSS 地址。
</p>
</div>
) : null}
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
<Input
id="add-feed-name"
autoFocus
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField
label="RSS 地址"
htmlFor="add-feed-rss"
required={kind === "rss_feed"}
error={errors.rssUrl}
hint={kind === "public_bt" ? "可选;之后可在站点的 RSS 里添加" : "地址加密保存,页面上只显示去掉密钥参数的地址"}
>
<Input
id="add-feed-rss"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="https://"
value={rssUrl}
aria-invalid={!!errors.rssUrl}
onChange={(e) => setRssUrl(e.target.value)}
disabled={busy}
/>
</FormField>
<FormField
label="网址"
htmlFor="add-feed-url"
required={kind === "public_bt"}
error={errors.baseUrl}
hint={kind === "rss_feed" ? "可选;留空时用 RSS 地址的域名" : undefined}
>
<Input
id="add-feed-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
<p className="text-meta text-muted-foreground">不需要账号。保存后立即抓一次 RSS。</p>
</form>
);
const validate = () => {
const fe: Record<string, string> = {};
if (!form.name.trim()) fe.name = "填写名称";
try {
const u = new URL(form.baseUrl.trim());
if (!/^https?:$/.test(u.protocol)) throw new Error();
} catch {
fe.baseUrl = "填完整地址,如 https://example.org";
}
let body = null;
if (kinds.length) {
// 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突
const b = credentialBody(cred, def?.key || "default");
if (b.errors) Object.assign(fe, b.errors);
body = b.body;
}
setErrors(fe);
return Object.keys(fe).length ? null : { body };
};
/** 保存站点(已建则按表单更新)→ 写凭证 → 刷新登录 → 检测健康 */
const test = async () => {
const v = validate();
if (!v) return;
setLogin(null);
setHealth(null);
try {
setPhase("保存站点…");
let site = created;
const base = { name: form.name.trim(), baseUrl: form.baseUrl.trim() };
if (!site) {
site = await create.mutateAsync({
kind: "pt",
key: def?.key ?? form.key,
adapter: def?.adapter ?? "nexusphp",
snapshotEnabled: true,
...base,
});
setCreated(site);
} else if (site.name !== base.name || site.baseUrl !== base.baseUrl) {
site = await update.mutateAsync({ id: site.id, body: base });
setCreated(site);
}
setStep(3);
if (v.body) {
setPhase("保存凭证…");
const saved = await put.mutateAsync({ siteId: site.id, body: v.body });
setPhase("登录中…");
const r = await refresh.mutateAsync({ id: site.id, accountId: saved.siteAccountId });
setLogin(r.result);
}
setPhase("检测健康…");
setHealth(await check.mutateAsync(site.id).catch(() => null));
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const finish = () => {
onClose();
if (created) navigate(consolePath("sites", created.id));
};
return (
<Dialog open onOpenChange={(o) => !o && !busy && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>添加站点</DialogTitle>
<DialogDescription asChild>
<ol className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta">
{stepLabels.map((l, i) => (
<li
key={l}
className={cn(
"flex items-center gap-1.5",
step === i ? "font-medium text-foreground" : "text-muted-foreground",
)}
>
<span
className={cn(
"grid h-5 w-5 place-items-center rounded-full border text-micro",
step > i
? "border-foreground bg-foreground text-background"
: step === i
? "border-foreground"
: "border-border",
)}
>
{step > i ? <Check className="h-3 w-3" /> : i + 1}
</span>
{l}
</li>
))}
</ol>
</DialogDescription>
</DialogHeader>
{step === 0 ? (
kindBody
) : feedKind ? (
feedBody
) : step === 1 ? (
<div className="min-w-0">
<div className="relative mb-3">
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
autoFocus
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索站点名或域名"
className="pl-9"
aria-label="搜索站点定义"
/>
</div>
<div className="max-h-[55vh] space-y-4 overflow-y-auto pr-1">
{defs.isPending ? (
<LoadingState rows={5} />
) : defs.error ? (
<ErrorState plain error={defs.error} onRetry={() => void defs.refetch()} />
) : (
<>
{groups.map((g) => (
<section key={g.adapter}>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
{ADAPTER_LABEL[g.adapter]} · {g.items.length}
</h3>
<ul className="grid gap-1.5 sm:grid-cols-2">
{g.items.map((d) => {
const taken = !!d.siteId;
return (
<li key={d.key}>
<button
type="button"
disabled={taken}
onClick={() => pick(d.key)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">
{d.name}
</span>
<span className="block truncate text-meta text-muted-foreground">
{hostOf(d.baseUrl)}
</span>
</span>
{taken ? (
<span className="shrink-0 text-meta text-muted-foreground">已添加</span>
) : !d.shippedEnabled ? (
<StatusBadge tone="warning" className="shrink-0">
未实测
</StatusBadge>
) : null}
</button>
</li>
);
})}
</ul>
</section>
))}
{!groups.length ? (
<p className="py-6 text-center text-caption text-muted-foreground">没有匹配的站点定义</p>
) : null}
<section>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
没有这个站点
</h3>
<button
type="button"
onClick={() => chooseKind("rss_feed")}
className="w-full rounded-lg border border-dashed border-border px-3 py-2 text-left text-caption text-muted-foreground hover:border-foreground/40 hover:text-foreground"
>
按「自定义 RSS 源」添加:只抓 RSS,不登录
</button>
</section>
</>
)}
</div>
<p className="mt-3 text-meta text-muted-foreground">
"未实测"的定义没有在真实站点上验证过,列表和登录可能失败。
</p>
</div>
) : step === 2 ? (
<form
id="site-add"
noValidate
onSubmit={(e) => {
e.preventDefault();
void test();
}}
className="max-h-[60vh] min-w-0 overflow-y-auto pr-1"
>
<div className="grid gap-x-4 sm:grid-cols-2">
<FormField label="名称" htmlFor="add-name" required error={errors.name}>
<Input
id="add-name"
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField label="地址" htmlFor="add-url" required error={errors.baseUrl} hint="站点换了域名时改这里">
<Input
id="add-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
</div>
{kinds.length ? (
<>
<div className="mt-1 mb-3 border-t border-border/60" />
<CredentialFields
value={cred}
onChange={setCred}
kinds={kinds}
errors={errors}
disabled={busy}
idPrefix="add"
/>
{kinds.length === 1 && kinds[0] === "api_key" ? (
<p className="text-meta text-muted-foreground">这个站点只支持 API Key 登录。</p>
) : null}
</>
) : (
<p className="text-caption text-muted-foreground">
不需要登录凭证。添加后在站点详情的「RSS」里新建 RSS。
</p>
)}
</form>
) : (
<div className="min-w-0 space-y-3" data-state={phase ? "running" : "done"} aria-busy={busy}>
<p className="text-caption text-muted-foreground">
{created ? `已添加「${created.name}」。` : ""}
{phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")}
</p>
{phase ? <LoadingState rows={2} /> : null}
<LoginResultView
result={login}
health={health}
onUseCookie={
kinds.includes("cookie")
? () => {
setCred(emptyCredential("cookie"));
setStep(2);
}
: undefined
}
/>
</div>
)}
<DialogFooter className="gap-2">
{step === 0 ? (
<Button variant="outline" onClick={onClose}>
取消
</Button>
) : feedKind ? (
<>
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
<ArrowLeft />
重选类型
</Button>
<Button type="submit" form="site-add-feed" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
) : step === 2 ? (
<>
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
<ArrowLeft />
重选站点
</Button>
<Button type="submit" form="site-add" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
{kinds.length ? "保存并测试" : "保存并检测"}
</Button>
</>
) : step === 3 ? (
<>
<Button variant="outline" onClick={() => setStep(2)} disabled={busy}>
<ArrowLeft />
返回修改
</Button>
<Button onClick={finish} disabled={busy}>
完成
</Button>
</>
) : (
<Button variant="ghost" onClick={() => setStep(0)}>
<ArrowLeft />
重选类型
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}