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]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-30 00:29:56 +08:00
1 parent ec65435328
commit 614262378e
20 files changed
+772 -304

No files matched your search

+208 -47
View File
@@ -1,8 +1,19 @@
import type { SiteDefinitionView, SiteHealthResult, SiteListItem, SiteLoginResult } from "@umt/contracts";
import { ArrowLeft, Check, LoaderCircle, Search } from "lucide-react";
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,
@@ -31,12 +42,26 @@ import { type CredentialDraft, CredentialFields, credentialBody, emptyCredential
import { LoginResultView } from "./site-dialogs.tsx";
import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fields.ts";
// 添加站点向导(site-center §4.3):① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康)
// 添加站点向导(site-center §4.3 + IA-2 站点类型):
// ⓪ 选类型 → PT 站:① 选定义 → ② 名称 / 域名 / 凭证 → ③ 测试(保存站点与凭证,刷新登录 + 检测健康);
// 公开 BT 站 / 自定义 RSS 源:① 名称 / 地址 / RSS 地址 → 保存后立即抓一次,进站点的 RSS Tab。
// 旧的"未适配站点(自定义 RSS 站点)"入口并入「自定义 RSS 源」类型。
const CUSTOM = "__custom_rss";
type Step = 1 | 2 | 3;
const STEP_LABEL = ["选择站点", "填写凭证", "测试"] as const;
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 {
@@ -44,10 +69,13 @@ function defaultKind(kinds: LoginKind[]): LoginKind {
return kinds.includes("cookie") ? "cookie" : (kinds[0] ?? "cookie");
}
export function SiteAddDialog({ onClose }: { onClose: () => void }) {
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const [step, setStep] = useState<Step>(1);
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: "" });
@@ -63,11 +91,55 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
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 custom = defKey === CUSTOM;
const kinds: LoginKind[] = custom ? [] : supportedKinds(def);
const kinds: LoginKind[] = supportedKinds(def);
const busy = phase !== null;
const groups = useMemo(() => {
@@ -88,6 +160,93 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
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> = {};
@@ -98,11 +257,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
} 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");
const b = credentialBody(cred, def?.key || "default");
if (b.errors) Object.assign(fe, b.errors);
body = b.body;
}
@@ -122,9 +280,10 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
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,
kind: "pt",
key: def?.key ?? form.key,
adapter: def?.adapter ?? "nexusphp",
snapshotEnabled: true,
...base,
});
setCreated(site);
@@ -161,25 +320,25 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
<DialogTitle>添加站点</DialogTitle>
<DialogDescription asChild>
<ol className="flex flex-wrap items-center gap-x-3 gap-y-1 text-meta">
{STEP_LABEL.map((l, i) => (
{stepLabels.map((l, i) => (
<li
key={l}
className={cn(
"flex items-center gap-1.5",
step === i + 1 ? "font-medium text-foreground" : "text-muted-foreground",
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 + 1
step > i
? "border-foreground bg-foreground text-background"
: step === i + 1
: step === i
? "border-foreground"
: "border-border",
)}
>
{step > i + 1 ? <Check className="h-3 w-3" /> : i + 1}
{step > i ? <Check className="h-3 w-3" /> : i + 1}
</span>
{l}
</li>
@@ -188,7 +347,11 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
</DialogDescription>
</DialogHeader>
{step === 1 ? (
{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" />
@@ -251,14 +414,14 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
) : null}
<section>
<h3 className="mb-1.5 text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">
未适配站点
没有这个站点
</h3>
<button
type="button"
onClick={() => pick(CUSTOM)}
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,不登录
按「自定义 RSS 源」添加:只抓 RSS,不登录
</button>
</section>
</>
@@ -288,13 +451,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
disabled={busy}
/>
</FormField>
<FormField
label="地址"
htmlFor="add-url"
required
error={errors.baseUrl}
hint={custom ? undefined : "站点换了域名时改这里"}
>
<FormField label="地址" htmlFor="add-url" required error={errors.baseUrl} hint="站点换了域名时改这里">
<Input
id="add-url"
inputMode="url"
@@ -305,18 +462,6 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
disabled={busy}
/>
</FormField>
{custom ? (
<FormField label="标识" htmlFor="add-key" required error={errors.key} hint="添加后不能改">
<Input
id="add-key"
className="font-mono"
value={form.key}
aria-invalid={!!errors.key}
onChange={(e) => setForm((f) => ({ ...f, key: e.target.value }))}
disabled={busy}
/>
</FormField>
) : null}
</div>
{kinds.length ? (
<>
@@ -335,7 +480,7 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
</>
) : (
<p className="text-caption text-muted-foreground">
不需要登录凭证。添加后在站点详情的"抓取与 RSS"里新建 RSS。
不需要登录凭证。添加后在站点详情的「RSS」里新建 RSS。
</p>
)}
</form>
@@ -362,7 +507,22 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
)}
<DialogFooter className="gap-2">
{step === 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 />
@@ -384,8 +544,9 @@ export function SiteAddDialog({ onClose }: { onClose: () => void }) {
</Button>
</>
) : (
<Button variant="outline" onClick={onClose}>
取消
<Button variant="ghost" onClick={() => setStep(0)}>
<ArrowLeft />
重选类型
</Button>
)}
</DialogFooter>