Files
UHD_Media_Tool/apps/web/app/components/sites/site-feed-dialog.tsx
T

297 lines
11 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 { 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>
);
}