Files
UHD_Media_Tool/apps/web/app/components/sites/site-add-dialog.tsx
T
rootandClaude Opus 5.5 614262378e feat(web): nav regroup, site RSS tab and site types (IA-2)
- sidebar: 概览 / 获取 / 整理 / 系统. Tasks moves to 系统; sites,
  subscriptions and rules form the 获取 group (ACQUIRE_GROUP_TITLE in
  nav.ts, the single place to rename it); standalone RSS nav item removed
- site detail gets an RSS tab with everything the RSS page had (create,
  edit, enable, fetch now, fetch state) plus recent items; tabs and
  header actions follow the site's capabilities
- old /console/sources links redirect to the site's RSS tab (?feed= looks
  up the owning site) or the site list; task links to RSS point at sites
- site list shows and filters by type; add-site wizard starts with a type
  step (PT still picks from the definition library; public BT / custom
  RSS source only need name, URL and RSS URL)

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-30 00:29:56 +08:00

557 lines
22 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.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 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"
>
<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>
);
}