feat(web): site center page, add-site wizard, credentials, health and user-data chart (FE-2)

- 资源 → 站点:卡片 / 列表切换(URL ?view= + localStorage 记住),筛选全部 / 已启用 / 需处理,
  按分享率 / 上传 / 上次登录排序;卡片显示账号状态、凭证类型、上次登录、上传 / 下载 / 分享率、
  等级 / VIP、做种、健康检测结果与时间、快照、限流
- 操作:检测健康、刷新登录(wait:true 同步结果)、更新凭证、编辑(限流 / 快照 / 代理)、启停、
  删除(确认框列出影响)
- 添加站点向导:按适配器分组选 23 个定义(未实测标注)→ 名称 / 域名覆盖 / 凭证三选一
  (馒头默认 API Key)→ 保存并测试(刷新登录 + 健康检测);验证码 / 二次验证提示改用 Cookie
  并链接待处理
- 站点详情:概览(用户数据磁贴 + 30 天上传 / 下载与分享率折线,recharts,无彩)/ 资源 /
  快照与 Feed / 账号(凭证管理、Passkey、登录历史)
- 设置"站点与账号"改为"站点默认"(限流、快照周期、UA、代理、做种页数)
- 侧栏改名"站点";smoke 补站点路由;依赖新增 recharts

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:08:29 +08:00
1 parent 81bf010e3e
commit f8362a3f4c
21 files changed
+3083 -1126

No files matched your search

+1 -1
View File
@@ -131,7 +131,7 @@ export default function DownloadsPage() {
empty={{
icon: Download,
title: f.active ? "没有符合筛选的下载任务" : "还没有下载任务",
description: f.active ? undefined : "自动收录或在站点工作台下载后出现在这里。",
description: f.active ? undefined : "自动收录或在站点页下载后出现在这里。",
}}
/>
+106 -40
View File
@@ -1,11 +1,23 @@
import type { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
import { ArrowLeft, Bot, Camera, Globe, Plus, Radar, SearchX, TriangleAlert } from "lucide-react";
import {
Activity,
ArrowLeft,
Bot,
Camera,
Globe,
LoaderCircle,
Plus,
Radar,
RefreshCw,
SearchX,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useParams, useSearchParams } from "react-router";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { snapshotStale, useSite, useSiteAccounts, useSnapshotSite } from "~/api/sites.ts";
import { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
@@ -13,21 +25,17 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
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 { 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 { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import {
accountSummary,
categoryLabel,
circuitLabel,
hrOf,
isFree,
relTime,
SITE_HEALTH,
} from "~/components/workbench/release-fields.ts";
import { categoryLabel, circuitLabel, hrOf, isFree, relTime } from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SiteAccountsPanel } from "~/components/workbench/site-accounts.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
@@ -89,18 +97,23 @@ function useDebounced<T>(value: T, ms = 400) {
return v;
}
const TABS = ["organized", "origin", "snapshots", "accounts"] as const;
const TABS = ["overview", "resources", "snapshots", "accounts"] as const;
/** 旧页签(整理视图 / 原站视图)落到"资源" */
const LEGACY_TAB: Record<string, string> = { organized: "resources", origin: "resources" };
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SiteWorkbenchPage() {
const { siteId = "" } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const rawTab = params.get("tab") ?? "";
const tab = (TABS as readonly string[]).includes(rawTab) ? rawTab : "organized";
const mapped = LEGACY_TAB[rawTab] ?? rawTab;
const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview";
const [creatingFeed, setCreatingFeed] = useState(false);
const site = useSite(siteId);
const accounts = useSiteAccounts(siteId);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const sites = useSites();
const snap = useSnapshotSite();
const [qInput, setQInput] = useState("");
@@ -118,7 +131,7 @@ export default function SiteWorkbenchPage() {
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
};
const releases = useSiteReleases(siteId, filters, live);
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
@@ -137,7 +150,7 @@ export default function SiteWorkbenchPage() {
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = site.data;
const s = sites.data?.items.find((x) => x.id === siteId);
const refreshFailed = !!releases.error && !!releases.data;
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
@@ -153,27 +166,49 @@ export default function SiteWorkbenchPage() {
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
if (site.error && !s) return <ErrorState error={site.error} onRetry={() => void site.refetch()} />;
if (sites.error && !sites.data) return <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
return (
<EmptyState
icon={Globe}
title="站点不存在或已删除"
description="回到站点页查看现有站点"
action={
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}>
<ArrowLeft />
返回站点
</Link>
</Button>
}
/>
);
const health = s ? SITE_HEALTH[s.healthStatus] : null;
const account = accountSummary(accounts.data?.items ?? []);
const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null;
const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null;
const circuit = s ? circuitLabel(s) : null;
const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
<PageHeader
title={s?.name ?? "站点工作台"}
title={s?.name ?? "站点"}
description={
s ? (
<span className="flex flex-wrap items-center gap-1.5">
{health ? <StatusBadge tone={health.tone}>{health.label}</StatusBadge> : null}
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
<a
href={s.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{hostOf(s.baseUrl)}
</a>
{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}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
<span className="text-meta text-muted-foreground">
快照 {relTime(coverage?.lastSnapshotAt ?? s.lastSnapshotAt)}
</span>
{snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
</span>
) : (
"加载中…"
@@ -184,15 +219,40 @@ export default function SiteWorkbenchPage() {
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("sites")}>
<ArrowLeft />
站点工作台
站点
</Link>
</Button>
{s ? (
<>
<Button
variant="outline"
size="sm"
disabled={!s.enabled || checks.isBusy(s.id, "health")}
onClick={() => void checks.checkHealth(s)}
>
{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} />
</>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
>
<Bot />问 Agent
<Bot />
<span className="sr-only sm:not-sr-only">问 Agent</span>
</Button>
</>
}
@@ -203,7 +263,7 @@ export default function SiteWorkbenchPage() {
onValueChange={(v) =>
setParams(
(p) => {
if (v === "organized") p.delete("tab");
if (v === "overview") p.delete("tab");
else p.set("tab", v);
return p;
},
@@ -212,13 +272,15 @@ export default function SiteWorkbenchPage() {
}
>
<TabsList aria-label="站点分节">
<TabsTrigger value="organized">整理视图</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
</TabsList>
<TabsContent value="organized">
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="resources">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
@@ -357,10 +419,6 @@ export default function SiteWorkbenchPage() {
</div>
</TabsContent>
<TabsContent value="origin">
<EmptyState icon={Globe} title="原站视图暂未提供" description={<>资源详情里有站点 ID,可据此到原站查看。</>} />
</TabsContent>
<TabsContent value="snapshots">
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
@@ -378,9 +436,17 @@ export default function SiteWorkbenchPage() {
</div>
</TabsContent>
<TabsContent value="accounts">{s ? <SiteAccountsPanel site={s} /> : <LoadingState rows={3} />}</TabsContent>
<TabsContent value="accounts">
{s ? <SiteAccountsTab site={s} checks={checks} onDialog={setDialog} /> : <LoadingState rows={3} />}
</TabsContent>
</Tabs>
<SiteDialogHost
dialog={dialog}
onClose={() => setDialog(null)}
onDeleted={() => navigate(consolePath("sites"))}
/>
<ReleaseDetailSheet
siteId={siteId}
release={selected}
+174 -119
View File
@@ -1,167 +1,222 @@
import type { Site, SiteAccount } from "@umt/contracts";
import { ArrowRight, Camera, Globe, Plus, RefreshCw } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { snapshotStale, useSiteAccounts, useSites, useSnapshotSite, useToggleSite } from "~/api/sites.ts";
import { Globe, LayoutGrid, List, Plus, RefreshCw } from "lucide-react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import { useSites } from "~/api/sites.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SiteAddDialog } from "~/components/sites/site-add-dialog.tsx";
import { SiteCard, SiteTable } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import { needsAttention, type SortKey, sortSites } from "~/components/sites/site-fields.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { accountSummary, circuitLabel, relTime, SITE_HEALTH } from "~/components/workbench/release-fields.ts";
import { ADAPTER_LABEL, SiteRegisterDialog } from "~/components/workbench/site-register-dialog.tsx";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
function SiteCard({ site, accounts }: { site: Site; accounts: SiteAccount[] }) {
const toggle = useToggleSite();
const snap = useSnapshotSite();
const health = SITE_HEALTH[site.healthStatus];
const account = accountSummary(accounts);
const stale = site.snapshotEnabled && snapshotStale(site);
const circuit = circuitLabel(site);
// 资源 → 站点:站点、凭证、健康、账号数据的唯一入口(docs/design/site-center.md §4)
return (
<div className="pg-slab flex min-w-0 flex-col rounded-xl p-4">
<div 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">
{site.key} · {ADAPTER_LABEL[site.adapter]}
</p>
</div>
<div className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
<label htmlFor={`site-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label>
<Switch
id={`site-${site.id}`}
checked={site.enabled}
disabled={toggle.isPending}
aria-label={site.enabled ? "停用站点" : "启用站点"}
onCheckedChange={(v) =>
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</div>
</div>
const VIEW_KEY = "umt.sites.view";
type View = "cards" | "list";
type Filter = "all" | "enabled" | "attention";
<div className="mt-3 flex flex-wrap gap-1.5">
<StatusBadge tone={health.tone}>{health.label}</StatusBadge>
<Link to={`${consolePath("sites", site.id)}?tab=accounts`} className="hover:opacity-80">
<StatusBadge tone={account.tone}>账号:{account.label}</StatusBadge>
</Link>
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
</div>
const FILTERS: { value: Filter; label: string }[] = [
{ value: "all", label: "全部" },
{ value: "enabled", label: "已启用" },
{ value: "attention", label: "需处理" },
];
const SORTS: { value: SortKey; label: string }[] = [
{ value: "name", label: "名称" },
{ value: "ratio", label: "分享率" },
{ value: "upload", label: "上传" },
{ value: "lastLogin", label: "上次登录" },
];
<dl className="mt-3 grid grid-cols-2 gap-x-4 gap-y-2 text-caption">
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">最近快照</dt>
<dd className="flex flex-wrap items-center gap-1.5 text-foreground">
{site.snapshotEnabled ? relTime(site.lastSnapshotAt) : "未开启快照"}
{stale ? <StatusBadge tone="warning">已过期</StatusBadge> : null}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">快照周期</dt>
<dd className="text-foreground">
{site.snapshotIntervalSec ? `${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认"}
</dd>
</div>
</dl>
<div className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button
variant="outline"
size="sm"
disabled={!site.enabled || snap.isPending}
title={site.enabled ? "抓取快照(受站点限流)" : "站点已停用"}
onClick={() =>
snap.mutate(
{ id: site.id },
{
onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
快照
</Button>
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites", site.id)}>
打开
<ArrowRight />
</Link>
</Button>
</div>
</div>
);
function readStoredView(): View | null {
try {
const v = localStorage.getItem(VIEW_KEY);
return v === "cards" || v === "list" ? v : null;
} catch {
return null;
}
}
export const meta = () => [{ title: "站点工作台 · UHD 媒体自动化控制台" }];
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SitesPage() {
const sites = useSites();
const [registering, setRegistering] = useState(false);
const accounts = useSiteAccounts();
const bySite = new Map<string, SiteAccount[]>();
for (const a of accounts.data?.items ?? []) bySite.set(a.siteId, [...(bySite.get(a.siteId) ?? []), a]);
const [params, setParams] = useSearchParams();
const [adding, setAdding] = useState(false);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const urlView = params.get("view");
const [storedView, setStoredView] = useState<View>("cards");
useEffect(() => {
const v = readStoredView();
if (v) setStoredView(v);
}, []);
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 setParam = (k: string, v: string | null) =>
setParams(
(p) => {
if (v) p.set(k, v);
else p.delete(k);
return p;
},
{ replace: true },
);
const setView = (v: View) => {
setStoredView(v);
try {
localStorage.setItem(VIEW_KEY, v);
} catch {
// 隐私模式等:只在本次会话记住
}
setParam("view", v);
};
// 待处理"改用 Cookie"深链:?credentials=<siteId>
const credFor = params.get("credentials");
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在数据到达时处理一次
useEffect(() => {
if (!credFor || !sites.data) return;
const s = sites.data.items.find((x) => x.id === credFor);
if (s) setDialog({ kind: "credentials", site: s, loginKind: "cookie" });
setParam("credentials", null);
}, [credFor, sites.data]);
const all = sites.data?.items ?? [];
const attention = all.filter(needsAttention).length;
const shown = sortSites(
all.filter((s) => (filter === "enabled" ? s.enabled : filter === "attention" ? needsAttention(s) : true)),
sort,
);
return (
<>
<PageHeader
title="站点工作台"
title="站点"
description="凭证、账号数据和健康状态;点站点名看资源、快照与 Feed"
actions={
<>
<Button variant="outline" size="sm" onClick={() => void sites.refetch()} disabled={sites.isFetching}>
<RefreshCw className={sites.isFetching ? "animate-spin" : undefined} />
刷新
</Button>
<Button size="sm" onClick={() => setRegistering(true)}>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus />
登记站点
添加站点
</Button>
</>
}
/>
{all.length ? (
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<Segmented
label="筛选"
value={filter}
onChange={(v) => setParam("filter", v === "all" ? null : v)}
options={FILTERS.map((f) => ({
...f,
label: f.value === "attention" && attention ? `${f.label} ${attention}` : f.label,
}))}
/>
<FilterSelect
label="排序"
value={sort === "name" ? "" : sort}
onChange={(v) => setParam("sort", v || null)}
allLabel="名称"
options={SORTS.filter((x) => x.value !== "name")}
/>
</div>
<Segmented
label="视图"
value={view}
onChange={(v) => setView(v as View)}
options={[
{ value: "cards", label: "卡片", icon: <LayoutGrid className="h-3.5 w-3.5" /> },
{ value: "list", label: "列表", icon: <List className="h-3.5 w-3.5" /> },
]}
/>
</div>
) : null}
{sites.isPending ? (
<div className="pg-slab rounded-xl p-4">
<LoadingState rows={4} />
</div>
) : sites.error && !sites.data ? (
<ErrorState error={sites.error} onRetry={() => void sites.refetch()} />
) : !sites.data?.items.length ? (
) : !all.length ? (
<EmptyState
icon={Globe}
title="还没有登记站点"
description="登记站点并填写账号后,在这里浏览和搜索资源。"
title="还没有站点"
description="添加站点并填写凭证后,这里显示账号数据和健康状态"
action={
<Button size="sm" onClick={() => setRegistering(true)}>
<Button size="sm" onClick={() => setAdding(true)}>
<Plus />
登记站点
添加站点
</Button>
}
/>
) : !shown.length ? (
<EmptyState
icon={Globe}
title={filter === "attention" ? "没有需处理的站点" : "没有已启用的站点"}
description="切换到「全部」查看其余站点"
/>
) : view === "list" ? (
<SiteTable sites={shown} checks={checks} onDialog={setDialog} />
) : (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{sites.data.items.map((s) => (
<SiteCard key={s.id} site={s} accounts={bySite.get(s.id) ?? []} />
{shown.map((s) => (
<SiteCard key={s.id} site={s} checks={checks} onDialog={setDialog} />
))}
</div>
)}
{registering ? <SiteRegisterDialog open onOpenChange={setRegistering} /> : null}
{adding ? <SiteAddDialog onClose={() => setAdding(false)} /> : null}
<SiteDialogHost dialog={dialog} onClose={() => setDialog(null)} />
</>
);
}
function Segmented({
label,
value,
onChange,
options,
}: {
label: string;
value: string;
onChange: (v: string) => void;
options: { value: string; label: string; icon?: React.ReactNode }[];
}) {
return (
<div role="radiogroup" aria-label={label} className="inline-flex gap-0.5 rounded-xl border border-border p-0.5">
{options.map((o) => (
// biome-ignore lint/a11y/useSemanticElements: 分段按钮
<button
key={o.value}
type="button"
role="radio"
aria-checked={value === o.value}
onClick={() => onChange(o.value)}
className={cn(
"inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-caption transition-colors",
value === o.value
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{o.icon}
{o.label}
</button>
))}
</div>
);
}
+2 -2
View File
@@ -84,7 +84,7 @@ export default function SourcesPage() {
<>
<PageHeader
title="来源健康"
description="单个站点的快照、Feed 和账号在站点工作台管理。"
description="单个站点的快照、Feed 和账号在站点页管理。"
actions={
<Button size="sm" onClick={() => setCreating(true)}>
<Plus />
@@ -104,7 +104,7 @@ export default function SourcesPage() {
empty={{
icon: Globe,
title: "还没有登记站点",
description: "在站点工作台登记。",
description: "在站点页添加。",
}}
/>
</DetailSection>