Files
UHD_Media_Tool/apps/web/app/components/sites/site-feed-dialog.tsx
T
rootandClaude Opus 5.5 c9b4ceb301 fix(web): 控制台界面细节与 uhd_web 控件规范对齐
- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕)
- 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块
- 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签
- 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮
- 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载
- 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 12:02:48 +08:00

283 lines
10 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 { SegmentedControl } from "~/components/ui/segmented.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { errorMessage } from "~/lib/api.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">
<p className="mb-1.5 text-caption font-medium text-foreground">用途</p>
<SegmentedControl
label="用途"
value={purpose}
onChange={setPurpose}
options={PURPOSES.map((p) => ({ value: p.value, label: p.label }))}
/>
<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>
);
}