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:
rootandClaude Opus 5.5 committed 2026-09-29 14:43:19 +08:00
1 parent 00ebdda7ed
commit 4a92dfd2dc
10 files changed
+512 -17

No files matched your search

@@ -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)}