Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
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<FeedPurpose>("generic");
|
||
const [note, setNote] = useState("");
|
||
const [accountId, setAccountId] = useState(() => defaultAccount(siteAccounts));
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [created, setCreated] = useState<FeedSource | null>(null);
|
||
const [fetchError, setFetchError] = useState<string | null>(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 (
|
||
<Dialog open onOpenChange={onOpenChange}>
|
||
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-lg">
|
||
<DialogHeader>
|
||
<DialogTitle>新建 RSS · {site.name}</DialogTitle>
|
||
<DialogDescription>地址加密保存,页面上只显示去掉 passkey 的地址。</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{created && feed ? (
|
||
<FirstFetch
|
||
feed={feed}
|
||
done={done}
|
||
timedOut={timedOut}
|
||
fetchError={fetchError}
|
||
warnings={created.warnings ?? []}
|
||
/>
|
||
) : (
|
||
<form
|
||
id="site-feed-form"
|
||
className="grid gap-1"
|
||
onSubmit={(e) => {
|
||
e.preventDefault();
|
||
submit();
|
||
}}
|
||
>
|
||
<FormField
|
||
label="RSS 地址"
|
||
htmlFor="site-feed-url"
|
||
required
|
||
error={error}
|
||
hint={
|
||
shown && shown !== trimmed ? (
|
||
<span className="break-all">
|
||
保存后显示为 <span className="font-mono">{shown}</span>
|
||
</span>
|
||
) : site.kind === "pt" ? (
|
||
"站点「控制面板 → RSS」里生成的链接"
|
||
) : (
|
||
"RSS 或 Atom 地址"
|
||
)
|
||
}
|
||
>
|
||
<Input
|
||
id="site-feed-url"
|
||
type="url"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
value={url}
|
||
aria-invalid={!!error}
|
||
placeholder={
|
||
site.kind === "pt" ? `${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…` : "https://"
|
||
}
|
||
onChange={(e) => setUrl(e.target.value)}
|
||
/>
|
||
</FormField>
|
||
{mismatch && site.kind !== "rss_feed" ? (
|
||
<p className="-mt-1 mb-2 text-caption text-warning">
|
||
地址域名与 {site.name}({new URL(site.baseUrl).hostname})不一致,仍可保存;确认没有填错站点。
|
||
</p>
|
||
) : null}
|
||
|
||
<div className="mb-3" role="radiogroup" aria-label="用途">
|
||
<p className="mb-1.5 text-caption font-medium text-foreground">用途</p>
|
||
<div className="inline-flex flex-wrap gap-1 rounded-xl border border-border p-1">
|
||
{PURPOSES.map((p) => (
|
||
// biome-ignore lint/a11y/useSemanticElements: 分段按钮,radio 语义由 role 表达
|
||
<button
|
||
key={p.value}
|
||
type="button"
|
||
role="radio"
|
||
aria-checked={purpose === p.value}
|
||
onClick={() => setPurpose(p.value)}
|
||
className={cn(
|
||
"rounded-lg px-3 py-1.5 text-caption transition-colors",
|
||
purpose === p.value
|
||
? "bg-foreground text-background"
|
||
: "text-muted-foreground hover:bg-accent hover:text-foreground",
|
||
)}
|
||
>
|
||
{p.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<p className="mt-1.5 text-caption text-muted-foreground">
|
||
{PURPOSES.find((p) => p.value === purpose)?.hint}
|
||
</p>
|
||
</div>
|
||
|
||
{siteAccounts.length > 1 ? (
|
||
<FormField label="账号" htmlFor="site-feed-account" hint="RSS 属于哪个账号(凭证失效时标记该账号)">
|
||
<Select value={accountId} onValueChange={setAccountId}>
|
||
<SelectTrigger id="site-feed-account" className="w-full">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper">
|
||
{siteAccounts.map((a) => (
|
||
<SelectItem key={a.id} value={a.id}>
|
||
{a.label}
|
||
{a.hasPasskey ? "(有 passkey)" : ""}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</FormField>
|
||
) : null}
|
||
|
||
<FormField label="备注" htmlFor="site-feed-note" hint="可选;留空显示为「站点名 RSS」">
|
||
<Input
|
||
id="site-feed-note"
|
||
value={note}
|
||
maxLength={80}
|
||
placeholder={`${site.name} RSS`}
|
||
onChange={(e) => setNote(e.target.value)}
|
||
/>
|
||
</FormField>
|
||
|
||
<p className="text-caption text-muted-foreground">
|
||
{account ? `账号 ${account.label}` : "这个站点没有账号,按地址里的 passkey 抓取"} · 解析器按站点定义 · 每
|
||
30 分钟抓一次,可在 RSS 详情里改
|
||
</p>
|
||
</form>
|
||
)}
|
||
|
||
<DialogFooter className="mt-2 gap-2">
|
||
{created ? (
|
||
<Button type="button" onClick={() => onOpenChange(false)}>
|
||
完成
|
||
</Button>
|
||
) : (
|
||
<>
|
||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||
取消
|
||
</Button>
|
||
<Button type="submit" form="site-feed-form" disabled={create.isPending}>
|
||
{create.isPending ? "保存中…" : "保存并抓取"}
|
||
</Button>
|
||
</>
|
||
)}
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="grid gap-3 text-caption">
|
||
<p className="font-mono break-all text-muted-foreground">{feed.urlDisplay}</p>
|
||
{warnings.map((w) => (
|
||
<p key={w} className="rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-warning">
|
||
{w}
|
||
</p>
|
||
))}
|
||
{err ? (
|
||
<p className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 break-words text-destructive">
|
||
<CircleAlert className="mt-0.5 size-4 shrink-0" />
|
||
<span>首次抓取失败:{err}</span>
|
||
</p>
|
||
) : done ? (
|
||
<p className="flex items-center gap-2 rounded-lg border border-success/40 bg-success/10 px-3 py-2 text-success">
|
||
<CheckCircle2 className="size-4 shrink-0" />
|
||
<span>已保存,首次抓取到 {count ?? 0} 条</span>
|
||
</p>
|
||
) : timedOut ? (
|
||
<p className="rounded-lg border border-border px-3 py-2 text-muted-foreground">
|
||
已保存。抓取还在排队(站点限流),结果稍后显示在 RSS 列表里。
|
||
</p>
|
||
) : (
|
||
<p className="flex items-center gap-2 rounded-lg border border-border px-3 py-2 text-muted-foreground">
|
||
<Loader2 className="size-4 shrink-0 animate-spin" />
|
||
<span>已保存,正在抓取…</span>
|
||
</p>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|