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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user