Merge branch 'ia2'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	CHANGELOG.md
#	docs/plan/00-first-version-plan.md
#	packages/agent/src/system-prompt.ts
This commit is contained in:
root committed 2026-09-30 00:30:42 +08:00
commit 7a24d23bbf
46 files changed
+1845 -1503

No files matched your search

+16 -7
View File
@@ -1,6 +1,6 @@
import { redirect } from "react-router";
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7)。
// 控制台 IA 重构后的旧链接 → 新路由(IA-1:docs/design/console-ia.md §3.3;IA-3:§7;IA-2 站点类型:RSS 并入站点)。
// 纯函数,各路由模块的 clientLoader 调用;nav.test 与冒烟都读 LEGACY_REDIRECTS。
// 设置页的 ?tab= 旧链接由设置页自己处理(IA-2)。
@@ -26,6 +26,15 @@ export function legacyRedirect(pathname: string, search: string): string | null
// IA-3:身份与元数据取消,待识别 / 外部身份 / 别名并入媒体概览
if (path === "/console/identity") return withRest(`/console/library?${IDENTITY_TAB[tab ?? ""] ?? IDENTITY_TAB.pool}`);
// IA-2:独立 RSS 页取消,RSS 在站点详情的 RSS Tab。?feed=<id> 要查所属站点,由 sources.tsx 的 clientLoader 处理
if (path === "/console/sources") {
const siteId = q.get("siteId");
if (q.get("feed")) return null;
if (siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=${tab === "snapshots" ? "snapshots" : "rss"}`;
if (q.get("new")) return "/console/sites?new=rss_feed";
return "/console/sites";
}
if (!tab) return null;
if (path === "/console/tasks") {
// FE-6:计划从任务页拆出,回到独立的计划页(?job= 透传)
@@ -37,11 +46,6 @@ export function legacyRedirect(pathname: string, search: string): string | null
return null;
}
if (path === "/console/subscriptions" && tab === "policies") return withRest("/console/rules");
if (path === "/console/sources" && (tab === "snapshots" || tab === "feeds")) {
const siteId = q.get("siteId");
if (tab === "snapshots" && siteId) return `/console/sites/${encodeURIComponent(siteId)}?tab=snapshots`;
return withRest("/console/sources");
}
return null;
}
@@ -53,8 +57,13 @@ export const LEGACY_REDIRECTS: { from: string; to: string }[] = [
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
{ from: "/console/tasks?tab=business", to: "/console/tasks" },
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
{ from: "/console/sources?tab=snapshots", to: "/console/sources" },
{ from: "/console/sources?tab=snapshots", to: "/console/sites" },
{ from: "/console/sources?tab=snapshots&siteId=abc", to: "/console/sites/abc?tab=snapshots" },
{ from: "/console/sources", to: "/console/sites" },
{ from: "/console/sources?health=failing", to: "/console/sites" },
{ from: "/console/sources?siteId=abc", to: "/console/sites/abc?tab=rss" },
{ from: "/console/sources?tab=feeds&siteId=abc", to: "/console/sites/abc?tab=rss" },
{ from: "/console/sources?new=1", to: "/console/sites?new=rss_feed" },
{ from: "/console/engine", to: "/console/tasks" },
{ from: "/console/engine?status=failed", to: "/console/tasks?status=failed" },
{ from: "/console/engine/run-1", to: "/console/tasks?run=run-1" },
+148 -83
View File
@@ -1,4 +1,4 @@
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
import type { FeedHealth, ResolutionStatus, SiteListItem, WorkbenchRelease } from "@umt/contracts";
import {
Activity,
ArrowLeft,
@@ -20,7 +20,6 @@ import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
import { DetailSection } 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 { consolePath } from "~/components/console/nav.ts";
@@ -31,11 +30,20 @@ import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import { ACCOUNT_OVERALL, healthLine, hostOf, overallStatus, primaryAccount } from "~/components/sites/site-fields.ts";
import {
ACCOUNT_OVERALL,
hasLogin,
healthLine,
hostOf,
kindLabel,
overallStatus,
primaryAccount,
siteCan,
} from "~/components/sites/site-fields.ts";
import { SiteOverview } from "~/components/sites/site-overview.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FeedTable } from "~/components/workbench/feed-table.tsx";
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
@@ -91,24 +99,53 @@ function useDebounced<T>(value: T, ms = 400) {
return v;
}
const TABS = ["overview", "resources", "origin", "snapshots", "accounts"] as const;
/** 旧页签(整理视图)落到"资源" */
const LEGACY_TAB: Record<string, string> = { organized: "resources" };
const TABS = ["overview", "resources", "origin", "snapshots", "rss", "accounts"] as const;
type Tab = (typeof TABS)[number];
/** 旧页签(整理视图)落到"资源";旧 RSS 页的 ?tab=feeds 落到 RSS */
const LEGACY_TAB: Record<string, string> = { organized: "resources", feeds: "rss" };
/** 按站点类型与可用获取方式显示的 Tab(IA-2):自定义 RSS 源没有原站视图、抓取、账号 */
function visibleTabs(s: SiteListItem | undefined): Tab[] {
if (!s) return [...TABS];
return TABS.filter((t) => {
if (t === "origin") return s.kind !== "rss_feed";
if (t === "snapshots") return siteCan(s, "list");
if (t === "rss") return siteCan(s, "rss");
if (t === "accounts") return hasLogin(s);
return true;
});
}
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
const TAB_LABEL: Record<Tab, string> = {
overview: "概览",
resources: "资源",
origin: "原站视图",
snapshots: "抓取",
rss: "RSS",
accounts: "账号",
};
export default function SiteWorkbenchPage() {
const { siteId = "" } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const rawTab = params.get("tab") ?? "";
const mapped = LEGACY_TAB[rawTab] ?? rawTab;
const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview";
const [creatingFeed, setCreatingFeed] = useState(false);
const [feedHealth, setFeedHealth] = useState("");
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const sites = useSites();
const snap = useSnapshotSite();
const site = sites.data?.items.find((x) => x.id === siteId);
const tabs = visibleTabs(site);
const rawTab = params.get("tab") ?? "";
let mapped = LEGACY_TAB[rawTab] ?? rawTab;
// 只有 RSS 的站点:旧的"抓取与 RSS"深链落到 RSS
if (mapped === "snapshots" && site && !siteCan(site, "list")) mapped = "rss";
const tab: Tab = (tabs as string[]).includes(mapped) ? (mapped as Tab) : "overview";
/** 旧 RSS 页 / 任务页的 ?feed=<id>:打开该 RSS 的详情 */
const feedParam = params.get("feed");
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
@@ -152,7 +189,7 @@ export default function SiteWorkbenchPage() {
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = sites.data?.items.find((x) => x.id === siteId);
const s = site;
const refreshFailed = !!releases.error && !!releases.data;
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
@@ -187,8 +224,11 @@ export default function SiteWorkbenchPage() {
/>
);
const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null;
const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null;
const login = !!s && hasLogin(s);
/** 有站点适配器(不只 RSS)才有首页健康检测 */
const checkable = !!s && s.capabilities.some((c) => c !== "rss");
const account = s && login ? ACCOUNT_OVERALL[overallStatus(s)] : null;
const health = s && checkable ? healthLine(s.lastHealth, s.lastHealthAt) : null;
const circuit = s ? circuitLabel(s) : null;
const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
@@ -207,6 +247,7 @@ export default function SiteWorkbenchPage() {
>
{hostOf(s.baseUrl)}
</a>
{s.kind !== "pt" ? <StatusBadge>{kindLabel(s.kind)}</StatusBadge> : null}
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
@@ -225,7 +266,7 @@ export default function SiteWorkbenchPage() {
站点
</Link>
</Button>
{s ? (
{s && checkable ? (
<>
<Button
variant="outline"
@@ -236,19 +277,21 @@ export default function SiteWorkbenchPage() {
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
<span className="sr-only sm:not-sr-only">检测健康</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
<SiteMoreMenu site={s} onDialog={setDialog} />
{login ? (
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
) : null}
</>
) : null}
{s ? <SiteMoreMenu site={s} onDialog={setDialog} /> : null}
<Button
variant="ghost"
size="sm"
@@ -268,6 +311,7 @@ export default function SiteWorkbenchPage() {
(p) => {
if (v === "overview") p.delete("tab");
else p.set("tab", v);
p.delete("feed");
return p;
},
{ replace: true },
@@ -275,11 +319,11 @@ export default function SiteWorkbenchPage() {
}
>
<TabsList aria-label="站点分节">
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="snapshots">抓取与 RSS</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
{tabs.map((t) => (
<TabsTrigger key={t} value={t}>
{TAB_LABEL[t]}
</TabsTrigger>
))}
</TabsList>
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
@@ -290,40 +334,44 @@ export default function SiteWorkbenchPage() {
onReset={anyFilter ? reset : undefined}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() =>
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("已开始抓取"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
刷新
</Button>
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
去站点搜
</Button>
{s && !siteCan(s, "list") ? null : (
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() =>
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("已开始抓取"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
刷新
</Button>
)}
{s && !siteCan(s, "search") ? null : (
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地资源,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
去站点搜
</Button>
)}
</>
}
>
@@ -375,7 +423,9 @@ export default function SiteWorkbenchPage() {
? "没有符合筛选条件的资源"
: q
? "本地资源里没有匹配的资源"
: "还没抓取过"
: s && !siteCan(s, "list")
? "还没有资源"
: "还没抓取过"
}
description={
all.length
@@ -384,9 +434,11 @@ export default function SiteWorkbenchPage() {
? "减少快速筛选条件再看。"
: coverage?.liveError
? `去站点搜失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「去站点搜」直接在站点搜索。"
: "点「刷新」抓取站点列表。"
: s && !siteCan(s, "list")
? "RSS 抓到的资源会出现在这里。"
: q
? "点「去站点搜」直接在站点搜索。"
: "点「刷新」抓取站点列表。"
}
/>
) : (
@@ -438,21 +490,34 @@ export default function SiteWorkbenchPage() {
<TabsContent value="origin">{s ? <SiteOriginPanel site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="snapshots">
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
<DetailSection
title="RSS"
actions={
<Button size="sm" variant="outline" onClick={() => setCreatingFeed(true)}>
<Plus />
新建 RSS
</Button>
}
>
<FeedTable siteId={siteId} creating={creatingFeed} onCreatingChange={setCreatingFeed} />
</DetailSection>
</div>
<TabsContent value="snapshots">{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
<TabsContent value="rss">
{/* 原独立 RSS 页的全部能力(IA-2):新建 / 编辑 / 启停 / 立即抓取 / 抓取状态 / 最近条目 */}
<FilterBar
onReset={feedHealth ? () => setFeedHealth("") : undefined}
actions={
<Button size="sm" onClick={() => setCreatingFeed(true)} disabled={!s}>
<Plus />
新建 RSS
</Button>
}
>
<FilterSelect
label="健康"
value={feedHealth}
onChange={setFeedHealth}
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
/>
</FilterBar>
<FeedTable
siteId={siteId}
health={(feedHealth || undefined) as FeedHealth | undefined}
filtered={!!feedHealth}
creating={creatingFeed}
onCreatingChange={setCreatingFeed}
openFeedId={feedParam}
/>
</TabsContent>
<TabsContent value="accounts">
+36 -9
View File
@@ -1,3 +1,4 @@
import { SITE_KINDS, type SiteKind } from "@umt/contracts";
import { Globe, LayoutGrid, List, PackageSearch, Plus, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
@@ -13,11 +14,15 @@ import { needsAttention, type SortKey, sortSites } from "~/components/sites/site
import { Button } from "~/components/ui/button.tsx";
import { cn } from "~/lib/utils.ts";
// 资源 → 站点:站点、凭证、健康、账号数据的唯一入口(docs/design/site-center.md §4)
// 获取 → 站点:站点、凭证、健康、账号数据、RSS 的唯一入口(docs/design/site-center.md §4);
// IA-2 起站点有类型(PT 站 / 公开 BT 站 / 自定义 RSS 源),列表可按类型筛选,?new=<类型> 直接打开添加。
const VIEW_KEY = "umt.sites.view";
type View = "cards" | "list";
type Filter = "all" | "enabled" | "attention";
const KIND_OPTIONS = (Object.keys(SITE_KINDS) as SiteKind[])
.filter((k) => SITE_KINDS[k].implemented)
.map((k) => ({ value: k, label: SITE_KINDS[k].label }));
const FILTERS: { value: Filter; label: string }[] = [
{ value: "all", label: "全部" },
@@ -45,7 +50,8 @@ export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SitesPage() {
const sites = useSites();
const [params, setParams] = useSearchParams();
const [adding, setAdding] = useState(false);
const newParam = params.get("new");
const [adding, setAdding] = useState<SiteKind | "any" | null>(null);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
@@ -58,6 +64,7 @@ export default function SitesPage() {
const view: View = urlView === "list" || urlView === "cards" ? urlView : storedView;
const filter = (FILTERS.find((f) => f.value === params.get("filter"))?.value ?? "all") as Filter;
const sort = (SORTS.find((s) => s.value === params.get("sort"))?.value ?? "name") as SortKey;
const kindFilter = KIND_OPTIONS.find((k) => k.value === params.get("kind"))?.value ?? "";
const setParam = (k: string, v: string | null) =>
setParams(
@@ -78,6 +85,14 @@ export default function SitesPage() {
setParam("view", v);
};
// 命令面板 / 旧 RSS 页的 ?new=1、?new=rss_feed:打开添加(带类型时跳过选类型)
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在参数出现时处理一次
useEffect(() => {
if (!newParam) return;
setAdding(newParam in SITE_KINDS ? (newParam as SiteKind) : "any");
setParam("new", null);
}, [newParam]);
// 待处理"改用 Cookie"深链:?credentials=<siteId>
const credFor = params.get("credentials");
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次
@@ -92,7 +107,11 @@ export default function SitesPage() {
const [searchDefaults, setSearchDefaults] = useState(false);
const attention = all.filter(needsAttention).length;
const shown = sortSites(
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
all.filter(
(s) =>
(filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true) &&
(!kindFilter || s.kind === kindFilter),
),
sort,
);
@@ -110,7 +129,7 @@ export default function SitesPage() {
<PackageSearch />
搜索站点
</Button>
<Button size="sm" onClick={() => setAdding(true)}>
<Button size="sm" onClick={() => setAdding("any")}>
<Plus />
添加站点
</Button>
@@ -130,6 +149,14 @@ export default function SitesPage() {
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
}))}
/>
{new Set(all.map((s) => s.kind)).size > 1 || kindFilter ? (
<FilterSelect
label="类型"
value={kindFilter}
onChange={(v) => setParam("kind", v || null)}
options={KIND_OPTIONS}
/>
) : null}
<FilterSelect
label="排序"
value={sort === "name" ? "" : sort}
@@ -160,9 +187,9 @@ export default function SitesPage() {
<EmptyState
icon={Globe}
title="还没有站点"
description="添加站点并填写凭证后,这里显示账号数据和健康状态"
description="添加 PT 站、公开 BT 站或 RSS 源后,这里显示账号数据、健康和 RSS 状态"
action={
<Button size="sm" onClick={() => setAdding(true)}>
<Button size="sm" onClick={() => setAdding("any")}>
<Plus />
添加站点
</Button>
@@ -171,8 +198,8 @@ export default function SitesPage() {
) : !shown.length ? (
<EmptyState
icon={Globe}
title={filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description="切换到「全部」查看其余站点"
title={kindFilter ? "没有这类站点" : filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description={kindFilter ? "清除类型筛选查看其余站点" : "切换到「全部」查看其余站点"}
/>
) : view === "list" ? (
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
@@ -184,7 +211,7 @@ export default function SitesPage() {
</div>
)}
{adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : null}
{adding ? <SiteAddDialog initialKind={adding === "any" ? null : adding} onClose={() => setAdding(null)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
<SearchDefaultDialog open={searchDefaults} onOpenChange={setSearchDefaults} sites={all} />
</>
+19 -70
View File
@@ -1,76 +1,25 @@
import type { FeedHealth } from "@umt/contracts";
import { Plus, RefreshCw } from "lucide-react";
import { useState } from "react";
import { useFeeds } from "~/api/feeds.ts";
import { useSites } from "~/api/sites.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { useQueryState } from "~/components/console/use-query-state.ts";
import { Button } from "~/components/ui/button.tsx";
import { FEED_HEALTH, FeedTable } from "~/components/workbench/feed-table.tsx";
import { legacyRedirectLoader } from "./redirects.ts";
import { FeedSource } from "@umt/contracts";
import { redirect } from "react-router";
import { apiParsed } from "~/api/parse.ts";
import { consolePath } from "~/components/console/nav.ts";
import { legacyRedirect } from "./redirects.ts";
// RSS:跨站点的 RSS 列表;站点健康只在站点页(DEAI-2 去掉重复的站点表)。
// IA-2:独立 RSS 页取消,RSS 属于站点(站点详情的 RSS Tab)。这里只做旧链接跳转,深链不失效:
// ?siteId= → 该站点 RSS Tab;?feed=<id> → 查 RSS 所属站点后打开它的详情;其余 → 站点列表。
export const meta = () => [{ title: "RSS · UHD 媒体自动化控制台" }];
// 旧的 ?tab=snapshots&siteId= 跳到站点详情的"快照与 Feed"
export const clientLoader = legacyRedirectLoader;
export async function clientLoader({ request }: { request: Request }) {
const url = new URL(request.url);
const feed = url.searchParams.get("feed");
if (feed) {
const f = await apiParsed(FeedSource, "/api/v1/feed-sources/:id", { pathParams: { id: feed } }).catch(() => null);
if (f?.siteId) return redirect(`${consolePath("sites", f.siteId)}?tab=rss&feed=${encodeURIComponent(feed)}`);
return redirect(consolePath("sites"));
}
return redirect(legacyRedirect(url.pathname, url.search) ?? consolePath("sites"));
}
export default function SourcesPage() {
const f = useQueryState(["health", "siteId"] as const);
const [creating, setCreating] = useState(false);
const sites = useSites();
const feeds = useFeeds({
health: (f.values.health || undefined) as FeedHealth | undefined,
siteId: f.values.siteId || undefined,
});
return (
<>
<PageHeader
title="RSS"
actions={
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
新建 RSS
</Button>
}
/>
<div className="space-y-8">
<DetailSection title="RSS 地址">
<FilterBar
onReset={f.active ? f.reset : undefined}
actions={
<Button variant="ghost" size="sm" onClick={() => void feeds.refetch()} disabled={feeds.isFetching}>
<RefreshCw className={feeds.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
}
>
<FilterSelect
label="健康"
value={f.values.health}
onChange={(v) => f.set("health", v)}
options={Object.entries(FEED_HEALTH).map(([value, h]) => ({ value, label: h.label }))}
/>
<FilterSelect
label="站点"
value={f.values.siteId}
onChange={(v) => f.set("siteId", v)}
options={(sites.data?.items ?? []).map((s) => ({ value: s.id, label: s.name }))}
/>
</FilterBar>
<FeedTable
health={(f.values.health || undefined) as FeedHealth | undefined}
siteId={f.values.siteId || undefined}
filtered={f.active}
creating={creating}
onCreatingChange={setCreating}
/>
</DetailSection>
</div>
</>
);
export default function LegacySourcesRedirect() {
return null;
}