Files
UHD_Media_Tool/apps/web/app/components/sites/site-card.tsx
T
rootandClaude Opus 5.5 c9b4ceb301 fix(web): 控制台界面细节与 uhd_web 控件规范对齐
- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 0.46 → 0.16,悬停加深,聚焦 / 出错保留光晕)
- 新增 SegmentedControl(滑动 seg-thumb),替换站点、下载、订阅、文件、智能体列表及对话框里的实心选中块;Tabs 改为同一枚滑块
- 开关型按钮统一 outline + aria-pressed 选中外观;页头 / 工具栏次操作描边、图标钮描边圆;文件排序改 Select;下载总速度加标签
- 分页一页放得下时只留总数、空列表不渲染、按容器宽度收起按钮
- 运行状态存储区重排;智能体列表页与会话页共用外壳路由,标题与左栏不再重挂载
- 站点卡片「⋯」不再单独换行;设置导航、状态计数、多选条件改玻璃选中态

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-08 12:02:48 +08:00

712 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { SiteListItem } from "@umt/contracts";
import {
Activity,
Camera,
Ellipsis,
ExternalLink,
KeyRound,
LoaderCircle,
Pencil,
Power,
RefreshCw,
Trash2,
} from "lucide-react";
import { type ReactNode, useState } from "react";
import { Link, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useFeeds } from "~/api/feeds.ts";
import { snapshotStale, useSnapshotSite, useToggleSite, useUpdateSite } from "~/api/sites.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { circuitLabel, relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import type { SiteDialog, useSiteChecks } from "./site-dialogs.tsx";
import {
ACCOUNT_OVERALL,
bytes,
cloudflareLine,
dataAccount,
hasLogin,
healthLine,
healthMessage,
hostOf,
kindLabel,
loginFailureText,
overallStatus,
primaryAccount,
ratioText,
siteCan,
} from "./site-fields.ts";
type Checks = ReturnType<typeof useSiteChecks>;
type Props = { site: SiteListItem; checks: Checks; onDialog: (d: SiteDialog) => void };
/** 按凭证类型给"更新 xx"文案 */
function updateCredLabel(site: SiteListItem): string {
const k = primaryAccount(site.accounts)?.authKind;
if (k === "api_key") return "更新 API Key";
if (k === "password") return "更新密码";
if (k === "cookie") return "更新 Cookie";
return "填写凭证";
}
function EnableSwitch({ site, withLabel }: { site: SiteListItem; withLabel?: boolean }) {
const toggle = useToggleSite();
return (
<span className="flex shrink-0 items-center gap-2 text-meta text-muted-foreground">
{withLabel ? <label htmlFor={`site-en-${site.id}`}>{site.enabled ? "已启用" : "已停用"}</label> : null}
<Switch
id={`site-en-${site.id}`}
checked={site.enabled}
disabled={toggle.isPending}
aria-label={`${site.enabled ? "停用" : "启用"}${site.name}`}
onCheckedChange={(v) =>
toggle.mutate(
{ id: site.id, enabled: v },
{
onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
</span>
);
}
/** 默认参与搜索:搜资源时「默认站点」集合 */
function SearchDefaultSwitch({ site }: { site: SiteListItem }) {
const update = useUpdateSite();
const on = site.searchDefault !== false;
return (
<span className="mr-auto flex items-center gap-2 text-meta text-muted-foreground">
<Switch
id={`site-sd-${site.id}`}
checked={on}
disabled={update.isPending}
onCheckedChange={(v) =>
update.mutate(
{ id: site.id, body: { searchDefault: v } },
{
onSuccess: () => toast.success(v ? `${site.name} 默认参与搜索` : `${site.name} 不再默认参与搜索`),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
/>
<label htmlFor={`site-sd-${site.id}`}>默认参与搜索</label>
</span>
);
}
/** "更多"菜单:更新凭证、编辑、快照、启停、删除 */
export function SiteMoreMenu({ site, onDialog }: { site: SiteListItem; onDialog: (d: SiteDialog) => void }) {
const [open, setOpen] = useState(false);
const snap = useSnapshotSite();
const toggle = useToggleSite();
const item = (
icon: ReactNode,
label: string,
onClick: () => void,
opts: { danger?: boolean; disabled?: boolean } = {},
) => (
<button
type="button"
role="menuitem"
disabled={opts.disabled}
onClick={() => {
setOpen(false);
onClick();
}}
className={cn(
"flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-caption transition-colors hover:bg-accent disabled:opacity-50 [&_svg]:h-3.5 [&_svg]:w-3.5",
opts.danger ? "text-destructive" : "text-foreground",
)}
>
{icon}
{label}
</button>
);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button variant="ghost" size="icon-sm" aria-label={`${site.name} 更多操作`}>
<Ellipsis />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-44 p-1.5" role="menu">
{hasLogin(site)
? item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))
: null}
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
{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 },
{
onSuccess: () => toast.success(site.enabled ? `已停用 ${site.name}` : `已启用 ${site.name}`),
onError: (e) => toast.error(errorMessage(e)),
},
),
)}
<div className="my-1 border-t border-border/60" />
{item(<Trash2 />, "删除站点", () => onDialog({ kind: "delete", site }), { danger: true })}
</PopoverContent>
</Popover>
);
}
function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { compact?: boolean }) {
const h = checks.isBusy(site.id, "health");
const l = checks.isBusy(site.id, "login");
const hasAccount = !!site.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
<Button
variant="outline"
size={compact ? "icon-sm" : "sm"}
disabled={h || !site.enabled}
aria-label="检测健康"
title={site.enabled ? "检测健康:首页能否打开、登录态是否有效,计入站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
{compact ? null : "检测健康"}
</Button>
<Button
variant="outline"
size={compact ? "icon-sm" : "sm"}
disabled={l || !hasAccount}
aria-label="刷新登录"
title={hasAccount ? "刷新登录:用凭证重新登录并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{compact ? null : "刷新登录"}
</Button>
</>
);
}
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>;
}
function Stat({ label, children, className }: { label: string; children: ReactNode; className?: string }) {
return (
<div className={cn("min-w-0", className)}>
<dt className="text-meta text-muted-foreground">{label}</dt>
<dd className="truncate text-caption font-medium text-foreground tabular-nums">{children}</dd>
</div>
);
}
/** 卡片头:名称、域名(外链)、启停;两种卡片共用,保证下方内容同一基线 */
function SiteCardHeader({ site }: { site: SiteListItem }) {
return (
<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>
<a
href={site.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="flex max-w-full items-center gap-1 text-meta text-muted-foreground hover:text-foreground"
>
<span className="truncate">{hostOf(site.baseUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
</div>
<EnableSwitch site={site} withLabel />
</header>
);
}
/** 没有登录的站点(公开 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")}>
<SiteCardHeader site={site} />
<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>
<div aria-hidden className="min-h-4 flex-1" />
<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">
<Link to={`${consolePath("sites", site.id)}?tab=rss`}>{f.loaded && !f.count ? "新建 RSS" : "管理 RSS"}</Link>
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
/** 内置公开 BT 站(专用适配器,无账号):出口、状态、资源更新,不走 RSS */
function PublicSiteCard({ site, onDialog }: Omit<Props, "checks">) {
const health = healthLine(site.lastHealth, site.lastHealthAt);
const stale = site.snapshotEnabled && snapshotStale(site);
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<SiteCardHeader site={site} />
<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">出口</dt>
<dd className="text-foreground">{site.proxy ? "代理" : "直连"}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={healthMessage(site.lastHealth) ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
<div className="col-span-2 min-w-0">
<dt className="text-meta text-muted-foreground">资源更新</dt>
<dd className={cn("text-foreground", stale && "text-warning")}>
{!site.snapshotEnabled
? "未开启"
: site.lastSnapshotAt
? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}`
: "还没抓取过"}
</dd>
</div>
</dl>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
/** 字幕站(SubHD、字幕库):出口与状态;搜索 / 下载在作品页「补字幕」里 */
function SubtitleSiteCard({ site, checks, onDialog }: Props) {
const health = healthLine(site.lastHealth, site.lastHealthAt);
const busy = checks.isBusy(site.id, "health");
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<SiteCardHeader site={site} />
<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">出口</dt>
<dd className="text-foreground">{site.proxy ? "代理" : "直连"}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={healthMessage(site.lastHealth) ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
</dl>
<p className="mt-2 text-meta text-muted-foreground">在作品页「补字幕」里搜索、下载。</p>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<Button
variant="outline"
size="sm"
disabled={busy || !site.enabled}
onClick={() => void checks.checkHealth(site)}
>
{busy ? <LoaderCircle className="animate-spin" /> : <Activity />}
检测健康
</Button>
<SiteMoreMenu site={site} onDialog={onDialog} />
</footer>
</article>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
if (site.kind === "subtitle") return <SubtitleSiteCard site={site} checks={checks} onDialog={onDialog} />;
if (!hasLogin(site) && siteCan(site, "list")) return <PublicSiteCard site={site} onDialog={onDialog} />;
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;
const health = healthLine(site.lastHealth, site.lastHealthAt);
const hmsg = healthMessage(site.lastHealth);
const stale = site.snapshotEnabled && snapshotStale(site);
const circuit = circuitLabel(site);
const cf = cloudflareLine(site.config);
const lastErr = acc && acc.status !== "active" ? acc.lastError : null;
return (
<article className={cn("pg-slab flex min-w-0 flex-col rounded-xl p-4", !site.enabled && "opacity-75")}>
<SiteCardHeader site={site} />
<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} />
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{cf ? (
cf.takeover ? (
<Link to={`${consolePath("sites", site.id)}?tab=origin`} title="在原站视图里接管会话,完成验证后交还">
<StatusBadge tone={cf.tone}>{cf.text}</StatusBadge>
</Link>
) : (
<StatusBadge tone={cf.tone}>{cf.text}</StatusBadge>
)
) : null}
</div>
{lastErr ? (
<p className="mt-2 line-clamp-2 text-meta text-warning" title={lastErr.message}>
{loginFailureText(lastErr.reason, lastErr.message, acc?.authKind).text}
</p>
) : null}
<div className="mt-3 rounded-lg border border-border/60 px-3 py-2.5">
{u ? (
<>
<dl className="grid grid-cols-3 gap-x-3 gap-y-2">
<Stat label="上传">{bytes(u.upload)}</Stat>
<Stat label="下载">{bytes(u.download)}</Stat>
<Stat label="分享率">{ratioText(u)}</Stat>
<Stat label="等级">{u.userLevel ?? "—"}</Stat>
<Stat label="做种">
{u.seeding}
{u.seedingSize ? <span className="text-muted-foreground"> · {bytes(u.seedingSize)}</span> : null}
</Stat>
{u.bonus !== null ? (
<Stat label="魔力">{u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })}</Stat>
) : (
<Stat label="未读消息">{u.messageUnread || "—"}</Stat>
)}
</dl>
<p className="mt-2 flex flex-wrap justify-between gap-x-3 text-meta text-muted-foreground">
<span>{u.username ?? ""}</span>
<span>
{u.messageUnread > 0 && u.bonus !== null ? `${u.messageUnread} 条未读 · ` : ""}数据{" "}
{relTime(dAcc?.userDataAt)}
</span>
</p>
</>
) : (
<p className="py-2 text-caption text-muted-foreground">
{acc ? "还没有用户数据。刷新登录后显示" : "还没有凭证。填写凭证后显示账号数据"}
</p>
)}
</div>
<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="text-foreground">{relTime(acc?.lastLoginAt)}</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">状态</dt>
<dd className="truncate" title={hmsg ?? undefined}>
<StatusDot tone={health.tone} />
{health.text}
</dd>
</div>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">资源更新</dt>
<dd className={cn("text-foreground", stale && "text-warning")}>
{!site.snapshotEnabled
? "未开启"
: site.lastSnapshotAt
? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}`
: "还没抓取过"}
</dd>
</div>
</dl>
<div aria-hidden className="min-h-4 flex-1" />
<footer className="mt-auto flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
{siteCan(site, "search") ? <SearchDefaultSwitch site={site} /> : <span className="mr-auto" />}
{/* 操作与「更多」放一组整体换行,窄卡片里「⋯」不会单独掉到下一行 */}
<div className="ml-auto flex items-center gap-2">
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
)}
<SiteMoreMenu site={site} onDialog={onDialog} />
</div>
</footer>
</article>
);
}
function StatusDot({ tone }: { tone: "success" | "warning" | "destructive" | "neutral" }) {
return (
<span
aria-hidden
className={cn(
"mr-1.5 inline-block h-1.5 w-1.5 rounded-full align-middle",
tone === "success"
? "bg-success"
: tone === "warning"
? "bg-warning"
: tone === "destructive"
? "bg-destructive"
: "bg-muted-foreground",
)}
/>
);
}
/** 列表视图:同字段,窄屏隐藏次要列 */
export function SiteTable({
sites,
checks,
onDialog,
variant = "pt",
}: {
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新;subtitle = 字幕站,只列出口与状态 */
variant?: "pt" | "public" | "subtitle";
}) {
const [params] = useSearchParams();
const name: DataColumn<SiteListItem> = {
key: "name",
header: "站点",
cell: (s) => (
<div className="min-w-0">
<Link to={consolePath("sites", s.id)} className="font-medium whitespace-nowrap text-foreground hover:underline">
{s.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(s.baseUrl)}</p>
</div>
),
};
const snapshot: DataColumn<SiteListItem> = {
key: "snapshot",
header: "资源更新",
hideBelow: "md",
cell: (s) => (
<span
className={cn(
"whitespace-nowrap",
s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground",
)}
>
{!siteCan(s, "list")
? "只有 RSS"
: !s.snapshotEnabled
? "未开启"
: s.lastSnapshotAt
? relTime(s.lastSnapshotAt)
: "还没有"}
</span>
),
};
const actions: DataColumn<SiteListItem> = {
key: "actions",
header: <span className="sr-only">操作</span>,
align: "right",
className: "w-px",
cell: (s) => (
<div className="flex items-center justify-end gap-1.5 whitespace-nowrap">
{hasLogin(s) && primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<EnableSwitch site={s} />
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
};
const health: DataColumn<SiteListItem> = {
key: "health",
header: "状态",
hideBelow: "sm",
cell: (s) => {
const h = healthLine(s.lastHealth, s.lastHealthAt);
return (
<span className="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
};
const columns: DataColumn<SiteListItem>[] =
variant === "subtitle"
? [
name,
{
key: "proxy",
header: "出口",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
health,
actions,
]
: variant === "public"
? [
name,
{
key: "proxy",
header: "出口",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{s.proxy ? "代理" : "直连"}</span>,
},
{
key: "search",
header: "资源搜索",
hideBelow: "sm",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{s.searchDefault ? "默认参与" : "手动选"}
</span>
),
},
snapshot,
actions,
]
: [
name,
{
key: "account",
header: "账号",
hideBelow: "xl",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "xl",
cell: (s) => (
<span className="whitespace-nowrap text-muted-foreground">
{relTime(primaryAccount(s.accounts)?.lastLoginAt)}
</span>
),
},
{
key: "traffic",
header: "上传 / 下载",
hideBelow: "md",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? (
<span className="whitespace-nowrap tabular-nums">
{bytes(u.upload)} / {bytes(u.download)}
</span>
) : (
"—"
);
},
},
{
key: "ratio",
header: "分享率",
align: "right",
cell: (s) => (
<span className="whitespace-nowrap tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>
),
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
return (
<span className="block max-w-40 truncate whitespace-nowrap" title={t}>
{t}
</span>
);
},
},
{
key: "health",
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="whitespace-nowrap text-caption" title={healthMessage(s.lastHealth) ?? h.text}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
snapshot,
actions,
];
// 站点数量不多:分组表格不分页
return (
<DataTable
rows={sites}
pagination={sites.length > 50}
paginationKey={params.toString()}
rowKey={(r) => r.id}
columns={columns}
/>
);
}