import { SITE_KINDS, type SiteDefinitionView, type SiteHealthResult, type SiteKind, type SiteListItem, type SiteLoginResult, } from "@umt/contracts"; import { ArrowLeft, Captions, 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 { 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; /** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */ const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "subtitle"]; const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server, subtitle: Captions } as const; /** 馒头默认 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 && KIND_ORDER.includes(initialKind) ? initialKind : null; const [kind, setKind] = useState(startKind); const [step, setStep] = useState(startKind ? 1 : 0); const [q, setQ] = useState(""); const [defKey, setDefKey] = useState(null); const [form, setForm] = useState({ name: "", baseUrl: "", key: "" }); const [cred, setCred] = useState(emptyCredential("cookie")); const [errors, setErrors] = useState>({}); const [created, setCreated] = useState(null); const [login, setLogin] = useState(null); const [health, setHealth] = useState(null); const [phase, setPhase] = useState(null); const create = useCreateSite(); const update = useUpdateSite(); const put = usePutCredentials(); const refresh = useRefreshLogin(); const check = useHealthCheck(); 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); }; 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]); // 公开 BT 站 / 字幕站:都是内置站,一键添加 const publicDefs = items.filter((d) => d.kind === kind); /** 内置公开 BT 站:一步建好(专用适配器、定时抓最新),进站点页 */ const addPublic = async (d: SiteDefinitionView) => { try { setPhase("保存中…"); const site = await create.mutateAsync({ kind: d.kind, 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 = (
    {KIND_ORDER.map((k) => { const spec = SITE_KINDS[k]; const Icon = KIND_ICON[k]; return (
  • ); })}
); const feedBody = (
{publicDefs.length ? (
    {publicDefs.map((d) => (
  • ))}
) : defs.isPending ? ( ) : null}

{kind === "subtitle" ? "不需要账号;在作品页「补字幕」里搜索、下载。" : "不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。"}

); const validate = () => { const fe: Record = {}; 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 ( !o && !busy && onClose()}> 添加站点
    {stepLabels.map((l, i) => (
  1. i ? "border-foreground bg-foreground text-background" : step === i ? "border-foreground" : "border-border", )} > {step > i ? : i + 1} {l}
  2. ))}
{step === 0 ? ( kindBody ) : feedKind ? ( feedBody ) : step === 1 ? (
setQ(e.target.value)} placeholder="搜索站点名或域名" className="pl-9" aria-label="搜索站点定义" />
{defs.isPending ? ( ) : defs.error ? ( void defs.refetch()} /> ) : ( <> {groups.map((g) => (

{ADAPTER_LABEL[g.adapter]} · {g.items.length}

    {g.items.map((d) => { const taken = !!d.siteId; return (
  • ); })}
))} {!groups.length ? (

没有匹配的站点定义

) : null}

没有这个站点

)}

标「未实测」的站点定义没有在真实站点上验证过,列表和登录可能失败。

) : step === 2 ? (
{ e.preventDefault(); void test(); }} className="max-h-[60vh] min-w-0 overflow-y-auto pr-1" >
setForm((f) => ({ ...f, name: e.target.value }))} disabled={busy} /> setForm((f) => ({ ...f, baseUrl: e.target.value }))} disabled={busy} />
{kinds.length ? ( <>
{kinds.length === 1 && kinds[0] === "api_key" ? (

这个站点只支持 API Key 登录。

) : null} ) : (

不需要登录凭证。添加后在站点详情的「RSS」里新建 RSS。

)} ) : (

{created ? `已添加「${created.name}」。` : ""} {phase ?? (login?.ok === false ? "可以返回修改凭证后重试,或先保存,之后在站点页处理。" : "")}

{phase ? : null} { setCred(emptyCredential("cookie")); setStep(2); } : undefined } />
)} {step === 0 ? ( ) : feedKind ? ( ) : step === 2 ? ( <> ) : step === 3 ? ( <> ) : ( )}
); }