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
@@ -44,6 +44,7 @@ import {
|
||||
userDataHistory,
|
||||
} from "../service/account.ts";
|
||||
import { audit, closeDecisions } from "../service/common.ts";
|
||||
import { feedDefaultsForSite } from "../service/feeds.ts";
|
||||
import { assignReleaseIdentity } from "../service/identity-admin.ts";
|
||||
|
||||
// 站点、站点账号、Feed、来源资源、观察的管理 API。凭证只写(存 secret,对外只给 secret_ref / 脱敏 URL)。
|
||||
@@ -515,27 +516,31 @@ export function sourcesRoutes(app: ModuleApp, rt: () => SourcesRuntime) {
|
||||
});
|
||||
route(app, D.feedSources.get, async ({ params }) => feedToApi(await getFeed(params.id)));
|
||||
route(app, D.feedSources.create, async ({ body, actor }) => {
|
||||
if (body.siteId) await getSite(body.siteId);
|
||||
const site = body.siteId ? await getSite(body.siteId) : null;
|
||||
if (body.siteAccountId) await checkAccount(body.siteAccountId, body.siteId ?? null);
|
||||
const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${body.name}` });
|
||||
// 站点内添加:账号 / 解析器 / 名称按站点推断,域名不符只提示
|
||||
const inferred = site ? await feedDefaultsForSite(db(), site, body.url, body.siteAccountId ?? null) : null;
|
||||
const name = body.name?.trim() || inferred?.name || new URL(body.url).hostname;
|
||||
const ref = await rt().ctx.secrets.create({ purpose: "feed_url", plaintext: body.url, label: `feed:${name}` });
|
||||
const row = await db()
|
||||
.insertInto("feed_source")
|
||||
.values({
|
||||
site_id: body.siteId ?? null,
|
||||
site_account_id: body.siteAccountId ?? null,
|
||||
name: body.name,
|
||||
site_account_id: inferred?.siteAccountId ?? body.siteAccountId ?? null,
|
||||
name,
|
||||
...(inferred ? { parser_version: inferred.parserVersion } : {}),
|
||||
purpose: body.purpose ?? "generic",
|
||||
kind: body.kind ?? "rss",
|
||||
url_secret_ref: ref,
|
||||
url_display: redactUrl(body.url),
|
||||
enabled: body.enabled ?? true,
|
||||
poll_interval_sec: body.pollIntervalSec ?? 900,
|
||||
poll_interval_sec: body.pollIntervalSec ?? inferred?.pollIntervalSec ?? 900,
|
||||
filter: jsonb(body.filter ?? {}),
|
||||
})
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
await audit(db(), actor, "feed_source.created", { type: "feed_source", id: row.id }, { url: row.url_display });
|
||||
return feedToApi(row);
|
||||
return inferred?.warnings.length ? { ...feedToApi<FeedSource>(row), warnings: inferred.warnings } : feedToApi(row);
|
||||
});
|
||||
route(app, D.feedSources.update, async ({ params, body, actor }) => {
|
||||
const feed = await getFeed(params.id);
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
type FeedFetchResult,
|
||||
type FeedFetchState,
|
||||
type FeedItem,
|
||||
getSiteDefinition,
|
||||
} from "@umt/adapters";
|
||||
import type { FeedHealth } from "@umt/contracts";
|
||||
import { type FeedSourceRow, sql } from "@umt/db";
|
||||
import { type FeedSourceRow, type SiteRow, sql } from "@umt/db";
|
||||
import { jsonb } from "../rows.ts";
|
||||
import type { SourcesRuntime } from "../runtime.ts";
|
||||
import { clip, type Exec, openDecision } from "./common.ts";
|
||||
@@ -53,6 +54,64 @@ export function extractSiteReleaseId(item: Pick<FeedItem, "guid" | "link" | "enc
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 站点 RSS 缺省轮询:30 分钟(站点请求克制:与快照下限一致;通用 Feed 仍缺省 15 分钟) */
|
||||
export const SITE_FEED_POLL_SEC = 1800;
|
||||
|
||||
/** 同站判定用的主机键:去掉 www.,取最后两级(hhanclub.net、tracker.hhanclub.net 视为同站) */
|
||||
function hostKey(u: string): string | null {
|
||||
try {
|
||||
const host = new URL(u).hostname.toLowerCase().replace(/^www\./, "");
|
||||
if (/^[\d.]+$/.test(host) || host.includes(":")) return host;
|
||||
return host.split(".").slice(-2).join(".");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点内添加 Feed 的推断:账号(未指定时取该站启用账号,优先带 passkey 的,再取最早的)、
|
||||
* 解析器(通用 RSS 解析 + 站点定义版本,便于追溯)、名称缺省「站点名 RSS」、轮询缺省 30 分钟;地址域名与站点不符只提示不拦。
|
||||
*/
|
||||
export async function feedDefaultsForSite(
|
||||
db: Exec,
|
||||
site: Pick<SiteRow, "id" | "key" | "name" | "base_url">,
|
||||
url: string,
|
||||
explicitAccountId: string | null,
|
||||
): Promise<{
|
||||
siteAccountId: string | null;
|
||||
parserVersion: string;
|
||||
name: string;
|
||||
pollIntervalSec: number;
|
||||
warnings: string[];
|
||||
}> {
|
||||
const def = getSiteDefinition(site.key);
|
||||
let siteAccountId = explicitAccountId;
|
||||
if (!siteAccountId) {
|
||||
const accs = await db
|
||||
.selectFrom("site_account")
|
||||
.select(["id", "passkey_secret_ref", "auth_kind"])
|
||||
.where("site_id", "=", site.id)
|
||||
.where("enabled", "=", true)
|
||||
.orderBy("created_at")
|
||||
.execute();
|
||||
const withPasskey = accs.filter((a) => a.passkey_secret_ref || a.auth_kind === "passkey");
|
||||
siteAccountId = (withPasskey[0] ?? accs[0])?.id ?? null;
|
||||
}
|
||||
const warnings: string[] = [];
|
||||
const feedHost = hostKey(url);
|
||||
const siteHosts = new Set([hostKey(site.base_url), def ? hostKey(def.domain) : null].filter(Boolean));
|
||||
if (feedHost && !siteHosts.has(feedHost)) {
|
||||
warnings.push(`地址域名 ${new URL(url).hostname} 与站点「${site.name}」不一致,已按该站保存;请确认没有填错站点`);
|
||||
}
|
||||
return {
|
||||
siteAccountId,
|
||||
parserVersion: def ? `rss@1;${def.umt.version}` : "rss@1",
|
||||
name: `${site.name} RSS`,
|
||||
pollIntervalSec: SITE_FEED_POLL_SEC,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
export function stateFromRow(f: FeedSourceRow): FeedFetchState {
|
||||
const cur = (f.cursor ?? {}) as StoredCursor;
|
||||
const err = f.last_error as FeedFetchState["lastError"] | null;
|
||||
|
||||
@@ -115,6 +115,37 @@ describe.skipIf(!up)("来源管理与站点工作台 API", () => {
|
||||
expect((await h.req("/feed-sources/00000000-0000-4000-8000-000000000000")).status).toBe(404);
|
||||
});
|
||||
|
||||
it("站点内添加 Feed:只给地址即可,账号 / 解析器 / 名称按站点推断;域名不符只提示", async () => {
|
||||
const acc = (await json(await h.req(`/site-accounts?siteId=${siteId}`))).body.data.items[0];
|
||||
const same = await json(
|
||||
await h.req("/feed-sources", { body: { siteId, url: "https://audiences.me/torrentrss.php?passkey=k1" } }),
|
||||
);
|
||||
expect(same.status).toBe(200);
|
||||
expect(same.body.data).toMatchObject({
|
||||
name: "观众 RSS",
|
||||
siteAccountId: acc.id,
|
||||
purpose: "generic",
|
||||
pollIntervalSec: 1800,
|
||||
});
|
||||
expect(same.body.data.parserVersion).toMatch(/^rss@1;audiences@/);
|
||||
expect(same.body.data.warnings).toBeUndefined();
|
||||
const other = await json(
|
||||
await h.req("/feed-sources", {
|
||||
body: { siteId, purpose: "promotion", name: "促销", url: "https://elsewhere.example/rss?passkey=k2" },
|
||||
}),
|
||||
);
|
||||
expect(other.status).toBe(200);
|
||||
expect(other.body.data.name).toBe("促销");
|
||||
expect(other.body.data.warnings?.[0]).toContain("elsewhere.example");
|
||||
const bare = await json(await h.req("/feed-sources", { body: { url: "https://any.example/feed.xml" } }));
|
||||
expect(bare.body.data).toMatchObject({
|
||||
name: "any.example",
|
||||
siteId: null,
|
||||
siteAccountId: null,
|
||||
parserVersion: "1",
|
||||
});
|
||||
});
|
||||
|
||||
it("快照:列表页 upsert 到 source_release,记录覆盖范围;站点级限流器每站一个实例", async () => {
|
||||
const r = await snapshotSite(h.rt, { siteId, kind: "periodic" });
|
||||
expect(r.status).toBe("ok");
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { feedHostKey } from "./feeds.ts";
|
||||
|
||||
describe("feedHostKey", () => {
|
||||
it("同站子域名 / www 归到同一键", () => {
|
||||
expect(feedHostKey("https://hhanclub.net/torrentrss.php?passkey=x")).toBe("hhanclub.net");
|
||||
expect(feedHostKey("https://www.hhanclub.net/")).toBe("hhanclub.net");
|
||||
expect(feedHostKey("https://rss.hhanclub.net/a")).toBe("hhanclub.net");
|
||||
});
|
||||
it("IP 原样;非法地址为 null", () => {
|
||||
expect(feedHostKey("http://127.0.0.1:8080/rss")).toBe("127.0.0.1");
|
||||
expect(feedHostKey("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,34 @@ export function useFeeds(f: { siteId?: string; health?: FeedHealth } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 单个 Feed;poll 为真时每 2 秒刷新(站点内添加后等首抓结果) */
|
||||
export function useFeed(id: string | null, poll = false) {
|
||||
return useQuery({
|
||||
queryKey: [...feedKeys.all, "one", id] as const,
|
||||
enabled: !!id,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: id ?? "" }, signal }),
|
||||
refetchInterval: poll ? 2 * TTL_SECOND : false,
|
||||
});
|
||||
}
|
||||
|
||||
/** 同站判定的主机键(与服务端 feedDefaultsForSite 一致):去 www.,取最后两级;IP 原样 */
|
||||
export function feedHostKey(u: string): string | null {
|
||||
try {
|
||||
const host = new URL(u).hostname.toLowerCase().replace(/^www\./, "");
|
||||
if (/^[\d.]+$/.test(host) || host.includes(":")) return host;
|
||||
return host.split(".").slice(-2).join(".");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 游标窗口里的条目数(最近一次成功抓取的 Feed 条目数);没抓过为 null */
|
||||
export function feedWindowCount(f: FeedSource): number | null {
|
||||
const w = f.cursor.window as { lastGuidSet?: unknown[] } | undefined;
|
||||
return Array.isArray(w?.lastGuidSet) ? w.lastGuidSet.length : null;
|
||||
}
|
||||
|
||||
export function useCreateFeed() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -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