feat(feeds): site-scoped RSS add infers account, parser and poll
Adding a feed from a site page now only asks for the URL, purpose (discover / track / promotion) and an optional note. POST /feed-sources with siteId infers the site account (passkey holder first), records the site definition version as parserVersion, defaults the name to "<site> RSS" and polling to 30 minutes, and returns non-blocking warnings when the URL host does not match the site. The dialog shows the redacted URL, fetches once right after saving and reports the item count or the error. The generic feed form on the sources page suggests linking a registered site when the URL host matches (can be undone). name is now optional. Takes effect after the stack is rebuilt. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
00ebdda7ed
commit
4a92dfd2dc
10 files changed
+512
-17
No files matched your search
@@ -0,0 +1,292 @@
|
||||
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>添加 {site.name} RSS</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>
|
||||
) : (
|
||||
"站点「控制面板 → RSS」里生成的链接"
|
||||
)
|
||||
}
|
||||
>
|
||||
<Input
|
||||
id="site-feed-url"
|
||||
type="url"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
value={url}
|
||||
aria-invalid={!!error}
|
||||
placeholder={`${site.baseUrl.replace(/\/$/, "")}/torrentrss.php?passkey=…`}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
{mismatch ? (
|
||||
<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
|
||||
分钟抓一次,可在 Feed 详情里改
|
||||
</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">
|
||||
已保存。抓取还在排队(站点限流),结果稍后显示在 Feed 列表里。
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
} from "@umt/contracts";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useCreateFeed, useUpdateFeed } from "~/api/feeds.ts";
|
||||
import { feedHostKey, useCreateFeed, useUpdateFeed } from "~/api/feeds.ts";
|
||||
import { FormField, zodFieldErrors } from "~/components/console/form-field.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
@@ -76,12 +76,26 @@ export function FeedFormDialog({
|
||||
}) {
|
||||
const [d, setD] = useState<Draft>(() => initial(feed));
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
// 新建时按地址域名建议关联已登记站点;用户动过站点选择后不再自动改
|
||||
const [siteTouched, setSiteTouched] = useState(!!feed);
|
||||
const [suggested, setSuggested] = useState<string | null>(null);
|
||||
const create = useCreateFeed();
|
||||
const update = useUpdateFeed();
|
||||
const pending = create.isPending || update.isPending;
|
||||
const siteAccounts = accounts.filter((a) => a.siteId === d.siteId);
|
||||
const set = <K extends keyof Draft>(k: K, v: Draft[K]) => setD((x) => ({ ...x, [k]: v }));
|
||||
|
||||
const onUrl = (url: string) => {
|
||||
if (siteTouched) return set("url", url);
|
||||
const key = feedHostKey(url.trim());
|
||||
const match = key ? sites.find((s) => feedHostKey(s.baseUrl) === key) : undefined;
|
||||
const accs = match ? accounts.filter((a) => a.siteId === match.id && a.enabled) : [];
|
||||
const acc = accs.find((a) => a.hasPasskey || a.authKind === "passkey") ?? accs[0];
|
||||
setSuggested(match?.id ?? null);
|
||||
setD((x) => ({ ...x, url, siteId: match?.id ?? "", siteAccountId: acc?.id ?? "" }));
|
||||
};
|
||||
const suggestedSite = suggested && d.siteId === suggested ? sites.find((s) => s.id === suggested) : undefined;
|
||||
|
||||
const submit = () => {
|
||||
let filter: Record<string, unknown> | undefined;
|
||||
if (d.filter.trim()) {
|
||||
@@ -95,7 +109,7 @@ export function FeedFormDialog({
|
||||
}
|
||||
}
|
||||
const raw = {
|
||||
name: d.name.trim(),
|
||||
...(d.name.trim() ? { name: d.name.trim() } : {}),
|
||||
purpose: d.purpose,
|
||||
siteId: d.siteId || null,
|
||||
siteAccountId: d.siteAccountId || null,
|
||||
@@ -109,7 +123,6 @@ export function FeedFormDialog({
|
||||
const e = zodFieldErrors(parsed.error.issues);
|
||||
if (e.pollIntervalSec) e.pollMin = "轮询间隔至少 1 分钟";
|
||||
if (e.url) e.url = feed ? "URL 格式不对" : "填写完整的 Feed URL";
|
||||
if (e.name) e.name = "名称不能为空";
|
||||
setErrors(e);
|
||||
return;
|
||||
}
|
||||
@@ -139,7 +152,12 @@ export function FeedFormDialog({
|
||||
submit();
|
||||
}}
|
||||
>
|
||||
<FormField label="名称" htmlFor="feed-name" required error={errors.name}>
|
||||
<FormField
|
||||
label="名称"
|
||||
htmlFor="feed-name"
|
||||
error={errors.name}
|
||||
hint={feed ? undefined : "可选;留空时用「站点名 RSS」或地址主机名"}
|
||||
>
|
||||
<Input
|
||||
id="feed-name"
|
||||
value={d.name}
|
||||
@@ -161,7 +179,7 @@ export function FeedFormDialog({
|
||||
value={d.url}
|
||||
aria-invalid={!!errors.url}
|
||||
placeholder="https://…"
|
||||
onChange={(e) => set("url", e.target.value)}
|
||||
onChange={(e) => onUrl(e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<div className="grid gap-x-3 sm:grid-cols-2">
|
||||
@@ -190,10 +208,35 @@ export function FeedFormDialog({
|
||||
onChange={(e) => set("pollMin", e.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="站点" htmlFor="feed-site">
|
||||
<FormField
|
||||
label="站点"
|
||||
htmlFor="feed-site"
|
||||
hint={
|
||||
suggestedSite ? (
|
||||
<span>
|
||||
按地址域名关联到「{suggestedSite.name}」
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 text-foreground underline underline-offset-2"
|
||||
onClick={() => {
|
||||
setSiteTouched(true);
|
||||
setSuggested(null);
|
||||
setD((x) => ({ ...x, siteId: "", siteAccountId: "" }));
|
||||
}}
|
||||
>
|
||||
不关联
|
||||
</button>
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Select
|
||||
value={d.siteId || NONE}
|
||||
onValueChange={(v) => setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }))}
|
||||
onValueChange={(v) => {
|
||||
if (!v) return;
|
||||
setSiteTouched(true);
|
||||
setD((x) => ({ ...x, siteId: v === NONE ? "" : v, siteAccountId: "" }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="feed-site" className="w-full">
|
||||
<SelectValue />
|
||||
@@ -216,7 +259,8 @@ export function FeedFormDialog({
|
||||
<Select
|
||||
value={d.siteAccountId || NONE}
|
||||
disabled={!d.siteId}
|
||||
onValueChange={(v) => set("siteAccountId", v === NONE ? "" : v)}
|
||||
// Radix 隐藏的原生 select 在选项列表同时变化时会回报 "",忽略
|
||||
onValueChange={(v) => v && set("siteAccountId", v === NONE ? "" : v)}
|
||||
>
|
||||
<SelectTrigger id="feed-account" className="w-full">
|
||||
<SelectValue />
|
||||
|
||||
@@ -10,6 +10,7 @@ import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.ts
|
||||
import { JsonView } from "~/components/console/json-view.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { SiteFeedDialog } from "~/components/sites/site-feed-dialog.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -123,6 +124,7 @@ export function FeedTable({
|
||||
const siteName = new Map((sites.data?.items ?? []).map((s) => [s.id, s.name]));
|
||||
const accountName = new Map((accounts.data?.items ?? []).map((a) => [a.id, a.label]));
|
||||
const open = feeds.data?.items.find((f) => f.id === openId) ?? null;
|
||||
const scopedSite = siteId ? sites.data?.items.find((s) => s.id === siteId) : undefined;
|
||||
const editing: FeedSource | "new" | null = creating ? "new" : editingFeed;
|
||||
const setEditing = (v: FeedSource | null) => {
|
||||
setEditingFeed(v);
|
||||
@@ -286,7 +288,14 @@ export function FeedTable({
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
|
||||
{editing ? (
|
||||
{editing === "new" && scopedSite ? (
|
||||
// 站点内添加:只填地址 / 用途 / 备注,其余按站点带出
|
||||
<SiteFeedDialog
|
||||
site={scopedSite}
|
||||
accounts={accounts.data?.items ?? []}
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
/>
|
||||
) : editing ? (
|
||||
<FeedFormDialog
|
||||
open
|
||||
onOpenChange={(o) => !o && setEditing(null)}
|
||||
|
||||
Reference in new issue
Block a user