Files
UHD_Media_Tool/apps/web/app/components/sites/site-feed-dialog.tsx
T
rootandClaude Opus 5.5 4480fc346d refactor(copy): 术语统一——收录版本、模型服务、智能体等(DEAI-2)
- 版本目标 → 收录版本(前后端用户可见字符串、能力描述、给模型的提示)
- Agent 上游 / 上游 → 模型服务;界面上的 Agent / Agent 会话 → 智能体 / 智能体会话
- 身份歧义 / 识别有歧义 / 无法确定作品 → 没识别出作品;待识别(池) → 未识别
- 建档 → 加入媒体库;资产 → 文件;库根 → 媒体库目录;纳管 → 本工具 / 外部任务;
  哨兵 → 标记文件 / 检查目录;站点快照 → 抓取;Feed / RSS 订阅 / 来源健康 → RSS
- 代码标识符、API 字段名、迁移文件不改;glossary.md 同步;smoke 选择器跟随

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 20:35:43 +08:00

293 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>添加 {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
分钟抓一次,可在 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>
);
}