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