import { type FeedPurpose, type FeedSource, redactUrl, type Site, type SiteAccount } from "@umt/contracts"; import { CheckCircle2, CircleAlert, Loader2 } from "lucide-react"; import { useEffect, useState } from "react"; import { feedHostKey, feedWindowCount, useCreateFeed, useFeed, useFetchFeedNow } from "~/api/feeds.ts"; import { FormField } from "~/components/console/form-field.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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { errorMessage } from "~/lib/api.ts"; import { cn } from "~/lib/utils.ts"; // 站点内添加 RSS:站点固定,账号 / 解析器 / 轮询由服务端按站点带出;只填地址、用途、可选备注。 // 保存后立即抓一次,在对话框里等结果(条目数或错误)。 const PURPOSES: { value: FeedPurpose; label: string; hint: string }[] = [ { value: "generic", label: "发现", hint: "全站新种,进入识别和收录评估" }, { value: "search", label: "追踪", hint: "按关键词、分类或收藏筛过的 RSS" }, { value: "promotion", label: "促销", hint: "只含免费 / 促销资源的 RSS" }, ]; /** 首抓最多等 90 秒(站点限流排队);超时后提示去列表看 */ const WAIT_MS = 90_000; function defaultAccount(accounts: SiteAccount[]): string { return (accounts.find((a) => a.hasPasskey || a.authKind === "passkey") ?? accounts[0])?.id ?? ""; } export function SiteFeedDialog({ site, accounts, onOpenChange, }: { site: Site; accounts: SiteAccount[]; onOpenChange: (o: boolean) => void; }) { const siteAccounts = accounts.filter((a) => a.siteId === site.id && a.enabled); const [url, setUrl] = useState(""); const [purpose, setPurpose] = useState("generic"); const [note, setNote] = useState(""); const [accountId, setAccountId] = useState(() => defaultAccount(siteAccounts)); const [error, setError] = useState(null); const [created, setCreated] = useState(null); const [fetchError, setFetchError] = useState(null); const [startedAt, setStartedAt] = useState(0); const [timedOut, setTimedOut] = useState(false); const create = useCreateFeed(); const fetchNow = useFetchFeedNow(); const polled = useFeed(created?.id ?? null, !!created && !timedOut); const feed = polled.data ?? created; const done = !!feed?.lastFetchAt; useEffect(() => { if (!created || done || timedOut) return; const t = setTimeout(() => setTimedOut(true), Math.max(0, startedAt + WAIT_MS - Date.now())); return () => clearTimeout(t); }, [created, done, timedOut, startedAt]); const trimmed = url.trim(); let valid = false; try { valid = /^https?:$/.test(new URL(trimmed).protocol); } catch { valid = false; } const shown = valid ? redactUrl(trimmed) : null; const siteHosts = [feedHostKey(site.baseUrl)]; const mismatch = valid && !siteHosts.includes(feedHostKey(trimmed)); const account = siteAccounts.find((a) => a.id === accountId); const submit = () => { if (!valid) { setError("填写完整的 RSS 地址(http / https)"); return; } setError(null); create.mutate( { siteId: site.id, url: trimmed, purpose, ...(note.trim() ? { name: note.trim() } : {}), ...(siteAccounts.length > 1 && accountId ? { siteAccountId: accountId } : {}), }, { onSuccess: (f) => { setCreated(f); setStartedAt(Date.now()); fetchNow.mutate(f.id, { onError: (e) => setFetchError(errorMessage(e)) }); }, onError: (e) => setError(errorMessage(e)), }, ); }; return ( 新建 RSS · {site.name} 地址加密保存,页面上只显示去掉 passkey 的地址。 {created && feed ? ( ) : (
{ e.preventDefault(); submit(); }} > 保存后显示为 {shown} ) : site.kind === "pt" ? ( "站点「控制面板 → RSS」里生成的链接" ) : ( "RSS 或 Atom 地址" ) } > setUrl(e.target.value)} /> {mismatch && site.kind !== "rss_feed" ? (

地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。

) : null}

用途

{PURPOSES.map((p) => ( // biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达 ))}

{PURPOSES.find((p) => p.value === purpose)?.hint}

{siteAccounts.length > 1 ? ( ) : null} setNote(e.target.value)} />

{account ? `账号 ${account.label}` : "这个站点没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每 30 分钟抓一次,可在 RSS 详情里改

)} {created ? ( ) : ( <> )}
); } function FirstFetch({ feed, done, timedOut, fetchError, warnings, }: { feed: FeedSource; done: boolean; timedOut: boolean; fetchError: string | null; warnings: string[]; }) { const err = feed.lastError ? String((feed.lastError as { message?: unknown }).message ?? JSON.stringify(feed.lastError)) : fetchError; const count = feedWindowCount(feed); return (

{feed.urlDisplay}

{warnings.map((w) => (

{w}

))} {err ? (

首次抓取失败:{err}

) : done ? (

已保存,首次抓取到 {count ?? 0} 条

) : timedOut ? (

已保存。抓取还在排队(站点限流),结果稍后显示在 RSS 列表里。

) : (

已保存,正在抓取…

)}
); }