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