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

+103 -14
View File
@@ -14,6 +14,7 @@ import {
import { type ReactNode, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { useFeeds } from "~/api/feeds.ts";
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
import { DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
@@ -29,13 +30,16 @@ import {
ACCOUNT_OVERALL,
bytes,
dataAccount,
hasLogin,
healthLine,
healthMessage,
hostOf,
kindLabel,
loginFailureText,
overallStatus,
primaryAccount,
ratioText,
siteCan,
} from "./site-fields.ts";
type Checks = ReturnType<typeof useSiteChecks>;
@@ -135,18 +139,22 @@ export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog:
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu">
{item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))}
{hasLogin(site)
? item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))
: null}
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
{item(
<Camera />,
"抓取最新资源",
() =>
snap.mutate(
{ id: site.id },
{ onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) },
),
{ disabled: !site.enabled },
)}
{siteCan(site, "list")
? item(
<Camera />,
"抓取最新资源",
() =>
snap.mutate(
{ id: site.id },
{ onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) },
),
{ disabled: !site.enabled },
)
: null}
{item(<Power />, site.enabled ? "停用站点" : "启用站点", () =>
toggle.mutate(
{ id: site.id, enabled: !site.enabled },
@@ -194,6 +202,7 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
}
function AccountBadges({ site }: { site: SiteListItem }) {
if (!hasLogin(site)) return null;
const st = ACCOUNT_OVERALL[overallStatus(site)];
// 正常不标;登录方式不做徽章(DEAI-2)
return st.tone === "success" ? null : <StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>;
@@ -208,7 +217,73 @@ function Stat({ label, children, className }: { label: string; children: ReactNo
);
}
/** 没有登录的站点(公开 BT、自定义 RSS 源):卡片只看 RSS */
function useSiteFeeds(siteId: string) {
// 列表页所有卡片共用一次 /feed-sources 请求
const feeds = useFeeds();
const mine = (feeds.data?.items ?? []).filter((f) => f.siteId === siteId);
const enabled = mine.filter((f) => f.enabled);
const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length;
const lastOk = mine
.map((f) => f.lastSuccessAt)
.filter((x): x is string => !!x)
.sort()
.at(-1);
return { loaded: !!feeds.data, count: mine.length, enabled: enabled.length, failing, lastOk };
}
function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
const f = useSiteFeeds(site.id);
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<header className="flex items-start justify-between gap-3">
<div className="min-w-0">
<Link
to={consolePath("sites", site.id)}
className="block truncate text-lead font-semibold text-foreground hover:underline"
>
{site.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(site.baseUrl)}</p>
</div>
<EnableSwitch site={site} withLabel />
</header>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<StatusBadge>{kindLabel(site.kind)}</StatusBadge>
{f.failing ? <StatusBadge tone="destructive">{f.failing} 个 RSS 抓取失败</StatusBadge> : null}
</div>
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 rounded-lg border border-border/60 px-3 py-2.5 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">RSS</dt>
<dd className="font-medium text-foreground tabular-nums">
{!f.loaded
? "—"
: f.count
? `${f.count} 个${f.enabled !== f.count ? `,${f.enabled} 个启用` : ""}`
: "还没有"}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">上次成功</dt>
<dd className="text-foreground">{relTime(f.lastOk)}</dd>
</div>
</dl>
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button asChild variant="outline" size="sm" className="mr-auto">
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "添加 RSS" : "管理 RSS"}</Link>
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
if (!hasLogin(site)) return <FeedSiteCard site={site} onDialog={onDialog} />;
return <AccountSiteCard site={site} checks={checks} onDialog={onDialog} />;
}
function AccountSiteCard({ site, checks, onDialog }: Props) {
const acc = primaryAccount(site.accounts);
const dAcc = dataAccount(site.accounts);
const u = dAcc?.userData ?? null;
@@ -242,6 +317,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
</header>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
{site.kind && site.kind !== "pt" ? <StatusBadge>{kindLabel(site.kind)}</StatusBadge> : null}
<AccountBadges site={site} />
{u?.vip ? <StatusBadge tone="neutral">VIP</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
@@ -311,7 +387,7 @@ export function SiteCard({ site, checks, onDialog }: Props) {
</dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<SearchDefaultSwitch site={site} />
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
@@ -371,6 +447,12 @@ export function SiteTable({
</div>
),
},
{
key: "kind",
header: "类型",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{kindLabel(s.kind)}</span>,
},
{
key: "account",
header: "账号",
@@ -425,6 +507,7 @@ export function SiteTable({
header: "状态",
hideBelow: "md",
cell: (s) => {
if (!hasLogin(s)) return <span className="text-muted-foreground">—</span>;
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="text-caption" title={healthMessage(s.lastHealth) ?? undefined}>
@@ -440,7 +523,13 @@ export function SiteTable({
hideBelow: "lg",
cell: (s) => (
<span className={s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground"}>
{!s.snapshotEnabled ? "未开启" : s.lastSnapshotAt ? relTime(s.lastSnapshotAt) : "还没有"}
{!siteCan(s, "list")
? "只有 RSS"
: !s.snapshotEnabled
? "未开启"
: s.lastSnapshotAt
? relTime(s.lastSnapshotAt)
: "还没有"}
</span>
),
},
@@ -451,7 +540,7 @@ export function SiteTable({
cell: (s) => (
<div className="flex items-center justify-end gap-1.5">
<EnableSwitch site={s} />
{primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
{hasLogin(s) && primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),