import type { FeedHealth, FeedSource } from "@umt/contracts"; import { Plus, RefreshCw, RotateCcw, Rss, Zap } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { feedGapHint, useFeedAction, 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 { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx"; import { JsonView } from "~/components/console/json-view.tsx"; import { KvList } from "~/components/console/kv-list.tsx"; import { PageHeader } from "~/components/console/page-header.tsx"; import { StatusBadge, type StatusTone } from "~/components/console/status-badge.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Switch } from "~/components/ui/switch.tsx"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { FeedFormDialog, PURPOSE_LABEL } from "~/components/workbench/feed-form.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx"; import { errorMessage } from "~/lib/api.ts"; import { formatDateTime } from "~/lib/utils.ts"; const HEALTH: Record = { unknown: { label: "未抓取", tone: "neutral" }, healthy: { label: "健康", tone: "success" }, degraded: { label: "降级", tone: "warning" }, failing: { label: "失败", tone: "destructive" }, circuit_open: { label: "熔断", tone: "destructive" }, disabled: { label: "已停用", tone: "neutral" }, }; const lastErrorText = (f: FeedSource) => f.lastError ? String((f.lastError as { message?: unknown }).message ?? JSON.stringify(f.lastError)) : null; /** 游标窗口摘要:服务端 cursor.window 的条目数与上次抓取时间 */ function cursorSummary(f: FeedSource): string { const w = f.cursor.window as { lastFetchedAt?: string | null; lastGuidSet?: unknown[] } | undefined; if (!w) return "尚无游标"; const n = Array.isArray(w.lastGuidSet) ? w.lastGuidSet.length : null; return `${n !== null ? `窗口 ${n} 条` : "窗口"}${w.lastFetchedAt ? ` · ${relTime(w.lastFetchedAt)}` : ""}`; } function FeedActions({ f }: { f: FeedSource }) { const fetchNow = useFetchFeedNow(); const action = useFeedAction(); return (
e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()} onCheckedChange={(v) => action.mutate( { id: f.id, action: v ? "enable" : "disable" }, { onSuccess: () => toast.success(v ? "已启用" : "已停用"), onError: (e) => toast.error(errorMessage(e)) }, ) } />
); } export default function SourcesPage() { const [health, setHealth] = useState(""); const [siteFilter, setSiteFilter] = useState(""); const feeds = useFeeds({ health: (health || undefined) as FeedHealth | undefined, siteId: siteFilter || undefined }); const sites = useSites(); const accounts = useSiteAccounts(); const [editing, setEditing] = useState(null); const [openId, setOpenId] = useState(null); 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])); const open = feeds.data?.items.find((f) => f.id === openId) ?? null; const columns: DataColumn[] = [ { key: "name", header: "名称", cell: (f) => (

{f.name}

{f.urlDisplay}

), }, { key: "health", header: "健康", cell: (f) => { const gap = feedGapHint(f); return (
{HEALTH[f.healthStatus].label} {gap ? ( {f.possibleGap ? "疑似缺口" : "可能漏项"} ) : null}
); }, }, { key: "purpose", header: "用途", hideBelow: "md", cell: (f) => PURPOSE_LABEL[f.purpose] }, { key: "site", header: "站点 / 账号", hideBelow: "lg", cell: (f) => ( {f.siteId ? (siteName.get(f.siteId) ?? "未知站点") : "—"} {f.siteAccountId ? ` · ${accountName.get(f.siteAccountId) ?? "账号"}` : ""} ), }, { key: "poll", header: "轮询", hideBelow: "lg", cell: (f) => {Math.round(f.pollIntervalSec / 60)} 分钟, }, { key: "success", header: "最后成功", hideBelow: "sm", cell: (f) => (

{relTime(f.lastSuccessAt)}

{f.consecutiveFailures ? (

连续失败 {f.consecutiveFailures}

) : null}
), }, { key: "ops", header: "", align: "right", cell: (f) => }, ]; return ( <> setEditing("new")}> 新建 Feed } /> Feed 站点快照 { setHealth(""); setSiteFilter(""); } : undefined } actions={ } > ({ value, label: h.label }))} /> ({ value: s.id, label: s.name }))} /> f.id} loading={feeds.isPending} error={feeds.error} onRetry={() => void feeds.refetch()} onRowClick={(f) => setOpenId(f.id)} selectedKey={openId} empty={{ icon: Rss, title: health || siteFilter ? "没有符合筛选的 Feed" : "还没有 Feed", description: "新建 RSS Feed 后按轮询策略抓取,新条目进入识别与收录评估。", }} /> {sites.isPending ? ( ) : sites.error ? ( void sites.refetch()} /> ) : !sites.data.items.length ? ( ) : (
{sites.data.items.map((s) => ( ))}
)}
!o && setOpenId(null)} title={open?.name ?? ""} description={open ? {open.urlDisplay} : undefined} footer={ open ? ( <> {open.healthStatus === "circuit_open" || open.healthStatus === "failing" || open.backoffUntil ? ( ) : null} ) : null } > {open ? ( <>
{HEALTH[open.healthStatus].label} {open.enabled ? "已启用" : "已停用"} {PURPOSE_LABEL[open.purpose]}
{feedGapHint(open) ? (

{feedGapHint(open)}

) : null} {lastErrorText(open) ? (

{lastErrorText(open)}

) : null} ) : null}
{editing ? ( !o && setEditing(null)} feed={editing === "new" ? null : editing} sites={sites.data?.items ?? []} accounts={accounts.data?.items ?? []} /> ) : null} ); }