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:
rootandClaude Opus 5.5 committed 2026-09-30 00:29:56 +08:00
1 parent ec65435328
commit 614262378e
20 files changed
+772 -304

No files matched your search

+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">