import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts"; import { ArrowLeft, Check, LoaderCircle, Search } 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):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康) const CUSTOM = "__custom_rss"; type Step = 1 | 2 | 3; const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const; const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] 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 }: { onClose: () => void }) { const defs = useSiteDefinitions(); const navigate = useNavigate(); const [step, setStep] = useState(1); 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 items = defs.data?.items ?? []; const def = items.find((d) => d.key === defKey); const custom = defKey === CUSTOM; const kinds: LoginKind[] = custom ? [] : 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.adapter === a && hit(d)).sort((x, y) => x.name.localeCompare(y.name, "zh-CN")), })).filter((g) => g.items.length); }, [items, q]); 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 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"; } if (custom && !/^[a-z][a-z0-9_-]*$/.test(form.key.trim())) fe.key = "小写字母开头,只含小写字母、数字、- 和 _"; let body = null; if (kinds.length) { // 账号标签用站点标识:服务端 secret 名按标签生成且全局唯一,多站都叫 default 会冲突 const b = credentialBody(cred, (custom ? form.key.trim() : 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({ key: custom ? form.key.trim() : (def?.key ?? form.key), adapter: custom ? "generic_rss" : (def?.adapter ?? "nexusphp"), snapshotEnabled: !custom, ...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()}> 添加站点
    {STEP_LABEL.map((l, i) => (
  1. i + 1 ? "border-foreground bg-foreground text-background" : step === i + 1 ? "border-foreground" : "border-border", )} > {step > i + 1 ? : i + 1} {l}
  2. ))}
{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} /> {custom ? ( setForm((f) => ({ ...f, key: e.target.value }))} disabled={busy} /> ) : null}
{kinds.length ? ( <>
{kinds.length === 1 && kinds[0] === "api_key" ? (

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

) : null} ) : (

不需要登录凭证。添加后在站点详情的"快照与 Feed"里新建 Feed。

)} ) : (

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

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