- 新专用解析器(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]>
607 lines
24 KiB
TypeScript
607 lines
24 KiB
TypeScript
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>
|
||
);
|
||
}
|