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