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:
1 parent
81bf010e3e
commit
f8362a3f4c
21 files changed
+3083
-1126
No files matched your search
@@ -131,7 +131,7 @@ export default function DownloadsPage() {
|
||||
empty={{
|
||||
icon: Download,
|
||||
title: f.active ? "没有符合筛选的下载任务" : "还没有下载任务",
|
||||
description: f.active ? undefined : "自动收录或在站点工作台下载后出现在这里。",
|
||||
description: f.active ? undefined : "自动收录或在站点页下载后出现在这里。",
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user