feat(web): nav regroup, site RSS tab and site types (IA-2)
- sidebar: 概览 / 获取 / 整理 / 系统. Tasks moves to 系统; sites, subscriptions and rules form the 获取 group (ACQUIRE_GROUP_TITLE in nav.ts, the single place to rename it); standalone RSS nav item removed - site detail gets an RSS tab with everything the RSS page had (create, edit, enable, fetch now, fetch state) plus recent items; tabs and header actions follow the site's capabilities - old /console/sources links redirect to the site's RSS tab (?feed= looks up the owning site) or the site list; task links to RSS point at sites - site list shows and filters by type; add-site wizard starts with a type step (PT still picks from the definition library; public BT / custom RSS source only need name, URL and RSS URL) Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
ec65435328
commit
614262378e
20 files changed
+772
-304
No files matched your search
@@ -111,7 +111,8 @@ export function FeedFormDialog({
|
||||
const raw = {
|
||||
...(d.name.trim() ? { name: d.name.trim() } : {}),
|
||||
purpose: d.purpose,
|
||||
siteId: d.siteId || null,
|
||||
// RSS 属于站点(IA-2):不能改成"不关联";没选站点时由服务端归入同主机的自定义 RSS 源
|
||||
...(d.siteId ? { siteId: d.siteId } : {}),
|
||||
siteAccountId: d.siteAccountId || null,
|
||||
pollIntervalSec: Math.round(Number(d.pollMin) * 60),
|
||||
enabled: d.enabled,
|
||||
@@ -242,12 +243,14 @@ export function FeedFormDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
<SelectItem value={NONE}>不关联站点</SelectItem>
|
||||
{sites.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{!d.siteId ? <SelectItem value={NONE}>按地址归入自定义 RSS 源</SelectItem> : null}
|
||||
{sites
|
||||
.filter((s) => s.capabilities.includes("rss"))
|
||||
.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormField>
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType, type FeedHealth, type FeedSource } from "@umt/contracts";
|
||||
import { EventType, type FeedHealth, type FeedSource, type Observation } from "@umt/contracts";
|
||||
import { RotateCcw, Rss, Zap } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { feedGapHint, feedKeys, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { feedGapHint, feedKeys, useFeedAction, useFeedObservations, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
|
||||
import { useSiteAccounts, useSites } from "~/api/sites.ts";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
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 { hasLogin } from "~/components/sites/site-fields.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -78,8 +80,42 @@ function FeedActions({ f }: { f: FeedSource }) {
|
||||
);
|
||||
}
|
||||
|
||||
const OBS_STATUS: Record<Observation["status"], { label: string; tone: StatusTone } | null> = {
|
||||
new: { label: "待识别", tone: "neutral" },
|
||||
linked: null,
|
||||
ignored: { label: "已忽略", tone: "neutral" },
|
||||
error: { label: "出错", tone: "warning" },
|
||||
};
|
||||
|
||||
/** RSS 详情里的最近条目:标题 + 发布时间;已关联资源的正常态不加徽章 */
|
||||
function RecentItems({ feedId }: { feedId: string }) {
|
||||
const obs = useFeedObservations(feedId);
|
||||
if (obs.isPending) return <LoadingState rows={3} />;
|
||||
if (obs.error) return <ErrorState plain title="读取条目失败" error={obs.error} onRetry={() => void obs.refetch()} />;
|
||||
// 按发布时间(没有时用首次发现)新的在前;接口按首次发现排序,同一次抓取的条目发现时间相同
|
||||
const at = (o: Observation) => o.sourcePublishedAt ?? o.firstSeenAt;
|
||||
const items = [...(obs.data?.items ?? [])].sort((a, b) => at(b).localeCompare(at(a)));
|
||||
if (!items.length) return <p className="text-caption text-muted-foreground">还没有抓到条目。</p>;
|
||||
return (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{items.map((o) => {
|
||||
const st = OBS_STATUS[o.status];
|
||||
return (
|
||||
<li key={o.id} className="flex min-w-0 items-start justify-between gap-3 py-2">
|
||||
<p className="min-w-0 text-caption break-words text-foreground">{o.title}</p>
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-meta whitespace-nowrap text-muted-foreground">
|
||||
{st ? <StatusBadge tone={st.tone}>{st.label}</StatusBadge> : null}
|
||||
{relTime(o.sourcePublishedAt ?? o.firstSeenAt)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。
|
||||
* RSS 列表 + 详情抽屉 + 新建/编辑对话框。站点详情的 RSS Tab 按 siteId 过滤(IA-2 起没有跨站点的 RSS 页)。
|
||||
*/
|
||||
/** 抓取完成 / 健康变化(outbox → SSE 桥)到达时节流刷新 Feed 列表 */
|
||||
function useLiveFeeds() {
|
||||
@@ -106,7 +142,10 @@ export function FeedTable({
|
||||
creating,
|
||||
onCreatingChange,
|
||||
filtered = false,
|
||||
openFeedId,
|
||||
}: {
|
||||
/** 深链打开的 RSS(?feed=<id>) */
|
||||
openFeedId?: string | null;
|
||||
siteId?: string;
|
||||
health?: FeedHealth;
|
||||
creating: boolean;
|
||||
@@ -119,7 +158,10 @@ export function FeedTable({
|
||||
const sites = useSites();
|
||||
const accounts = useSiteAccounts();
|
||||
const [editingFeed, setEditingFeed] = useState<FeedSource | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(null);
|
||||
const [openId, setOpenId] = useState<string | null>(openFeedId ?? null);
|
||||
useEffect(() => {
|
||||
if (openFeedId) setOpenId(openFeedId);
|
||||
}, [openFeedId]);
|
||||
const action = useFeedAction();
|
||||
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]));
|
||||
@@ -167,17 +209,25 @@ export function FeedTable({
|
||||
hideBelow: "md",
|
||||
cell: (f) => (f.purpose === "generic" ? null : PURPOSE_LABEL[f.purpose]),
|
||||
},
|
||||
{
|
||||
key: "site",
|
||||
header: "站点 / 账号",
|
||||
hideBelow: "lg",
|
||||
cell: (f) => (
|
||||
<span className="text-muted-foreground">
|
||||
{f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}
|
||||
{f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
// 站点详情里只列账号(站点已知);没有登录的站点不显示这一列
|
||||
...(scopedSite && !hasLogin(scopedSite)
|
||||
? []
|
||||
: [
|
||||
{
|
||||
key: "site",
|
||||
header: siteId ? "账号" : "站点 / 账号",
|
||||
hideBelow: "lg" as const,
|
||||
cell: (f: FeedSource) => (
|
||||
<span className="text-muted-foreground">
|
||||
{siteId
|
||||
? f.siteAccountId
|
||||
? (accountName.get(f.siteAccountId) ?? "账号")
|
||||
: "—"
|
||||
: `${f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"}${f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""}`}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]),
|
||||
{
|
||||
key: "poll",
|
||||
header: "轮询",
|
||||
@@ -287,6 +337,9 @@ export function FeedTable({
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
<DetailSection title="最近条目">
|
||||
<RecentItems feedId={open.id} />
|
||||
</DetailSection>
|
||||
<JsonView label="过滤条件" value={open.filter} />
|
||||
<JsonView label="游标原文" value={open.cursor} />
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user