feat(web): 站点按类型分组、列表模式修样式、下掉自定义 RSS 源与索引器入口;智能体会话分对话 / 自动巡检两栏

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-02 23:56:27 +08:00
1 parent 8da8679a04
commit 8eb8a68295
11 files changed
+364 -312

No files matched your search

@@ -1,23 +1,29 @@
import { Bot, ChevronRight } from "lucide-react";
import { type ReactNode, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { type AgentRunGroup, type AgentRunView, TRIGGER_LABEL, useAgentRuns } from "~/api/agent.ts";
import { CHEVRON_CLASS } from "~/components/agentui/disclosure.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { cn } from "~/lib/utils.ts";
import { contextLabel, RunStatusBadge } from "./agent-conversation.tsx";
import { shortGoalTitle, splitContextGoal } from "./agent-store.ts";
// 会话列表(AGUI-3):会话页左栏与抽屉的「历史」弹层共用。
// 筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
// 分两栏:对话(用户发起)/ 自动巡检(定时、事件触发),子会话随父会话;栏内筛选只有搜索 + 全部 / 等你处理 / 失败;「等你处理」(等选方案、等审批)置顶;子会话收在父会话下面。
export type HistoryView = "all" | "pending" | "failed";
export type HistoryFilters = { q: string; view: HistoryView };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all" };
export type HistoryFilters = { q: string; view: HistoryView; group: AgentRunGroup };
export const EMPTY_FILTERS: HistoryFilters = { q: "", view: "all", group: "conversation" };
const GROUPS: { value: AgentRunGroup; label: string }[] = [
{ value: "conversation", label: "对话" },
{ value: "auto", label: "自动巡检" },
];
const VIEWS: { value: HistoryView; label: string }[] = [
{ value: "all", label: "全部" },
@@ -45,7 +51,7 @@ export type HistoryRow = { run: AgentRunView; children: AgentRunView[] };
*/
export function arrangeHistory(
runs: AgentRunView[],
f: HistoryFilters,
f: Pick<HistoryFilters, "q" | "view">,
limit = Number.POSITIVE_INFINITY,
): { pinned: HistoryRow[]; rows: HistoryRow[] } {
const uniq = [...new Map(runs.map((r) => [r.id, r] as const)).values()];
@@ -204,9 +210,9 @@ export function AgentSessionList({
if (remember) drawerMemory.filters = next;
return next;
});
const runs = useAgentRuns();
const runs = useAgentRuns(undefined, undefined, f.group);
// 等你处理的会话可能是很早以前建的:单独按 waiting 取一页,保证置顶
const waiting = useAgentRuns("waiting");
const waiting = useAgentRuns("waiting", undefined, f.group);
const loaded = runs.data?.pages.flatMap((p) => p.items) ?? [];
const pending = waiting.data?.pages.flatMap((p) => p.items) ?? [];
const arranged = arrangeHistory([...pending.filter(awaitingUser), ...loaded], f);
@@ -236,6 +242,15 @@ export function AgentSessionList({
return (
<nav aria-label="智能体会话列表" className={cn("flex min-h-0 flex-col", className)}>
<div className="mb-2 space-y-1.5">
<Tabs value={f.group} onValueChange={(v) => set({ group: v as AgentRunGroup })} className="pb-1">
<TabsList aria-label="会话分栏" className="grid w-full grid-cols-2">
{GROUPS.map((g) => (
<TabsTrigger key={g.value} value={g.value}>
{g.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<Input
type="search"
value={f.q}
@@ -281,7 +296,9 @@ export function AgentSessionList({
? f.view === "pending"
? "没有等你处理的会话"
: "没有符合条件的会话"
: "还没有会话,写一个目标开始"
: f.group === "auto"
? "还没有巡检会话,巡检到点后会出现在这里"
: "还没有会话,写一个目标开始"
}
/>
) : (
@@ -12,7 +12,7 @@ export type DataColumn<T> = {
header: ReactNode;
cell: (row: T) => ReactNode;
/** 窄屏隐藏该列,避免横向溢出 */
hideBelow?: "sm" | "md" | "lg";
hideBelow?: "sm" | "md" | "lg" | "xl";
align?: "left" | "right";
className?: string;
};
@@ -31,6 +31,7 @@ const HIDE: Record<NonNullable<DataColumn<unknown>["hideBelow"]>, string> = {
sm: "hidden sm:table-cell",
md: "hidden md:table-cell",
lg: "hidden lg:table-cell",
xl: "hidden xl:table-cell",
};
/**
+34 -144
View File
@@ -12,8 +12,6 @@ import { ArrowLeft, Check, Globe, LoaderCircle, Rss, Search, Server, ShieldCheck
import { useMemo, useState } from "react";
import { useNavigate } from "react-router";
import { toast } from "sonner";
import { useFetchFeedNow } from "~/api/feeds.ts";
import { apiParsed } from "~/api/parse.ts";
import {
useCreateSite,
useHealthCheck,
@@ -49,20 +47,12 @@ import { ADAPTER_LABEL, hostOf, type LoginKind, supportedKinds } from "./site-fi
type Step = 0 | 1 | 2 | 3;
const PT_STEPS = ["选择类型", "选择站点", "填写凭证", "测试"] as const;
const FEED_STEPS = ["选择类型", "填写地址"] as const;
const FEED_STEPS = ["选择类型", "选择站点"] as const;
const GROUP_ORDER = ["nexusphp", "mteam_api", "api", "generic_rss"] as const;
const FeedPage = cursorPage(FeedSource);
const KIND_ORDER: SiteKind[] = ["pt", "public_bt", "rss_feed", "torznab"];
/** 自定义 RSS 源与索引器入口暂时不开放(已有的 RSS 源站点照常显示) */
const KIND_ORDER: SiteKind[] = ["pt", "public_bt"];
const KIND_ICON = { pt: ShieldCheck, public_bt: Globe, rss_feed: Rss, torznab: Server } as const;
function isHttpUrl(v: string): boolean {
try {
return /^https?:$/.test(new URL(v.trim()).protocol);
} catch {
return false;
}
}
/** 馒头默认 API Key,其余默认 Cookie(验证码站点账密大概率失败) */
function defaultKind(kinds: LoginKind[]): LoginKind {
if (kinds.includes("api_key") && !kinds.includes("cookie")) return "api_key";
@@ -72,10 +62,9 @@ function defaultKind(kinds: LoginKind[]): LoginKind {
export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; initialKind?: SiteKind | null }) {
const defs = useSiteDefinitions();
const navigate = useNavigate();
const startKind = initialKind && SITE_KINDS[initialKind]?.implemented ? initialKind : null;
const startKind = initialKind && KIND_ORDER.includes(initialKind) ? initialKind : null;
const [kind, setKind] = useState<SiteKind | null>(startKind);
const [step, setStep] = useState<Step>(startKind ? 1 : 0);
const [rssUrl, setRssUrl] = useState("");
const [q, setQ] = useState("");
const [defKey, setDefKey] = useState<string | null>(null);
const [form, setForm] = useState({ name: "", baseUrl: "", key: "" });
@@ -91,7 +80,6 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
const put = usePutCredentials();
const refresh = useRefreshLogin();
const check = useHealthCheck();
const fetchFeed = useFetchFeedNow();
const feedKind = kind !== null && kind !== "pt";
const stepLabels: readonly string[] = feedKind ? FEED_STEPS : PT_STEPS;
@@ -102,41 +90,6 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
setStep(1);
};
/** 公开 BT 站 / 自定义 RSS 源:建站点 + RSS(服务端一个事务),立即抓一次,进 RSS Tab */
const saveFeedSite = async () => {
const fe: Record<string, string> = {};
const url = rssUrl.trim();
const base = form.baseUrl.trim();
if (!form.name.trim()) fe.name = "填写名称";
if (kind === "rss_feed" && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
if (kind === "public_bt" && url && !isHttpUrl(url)) fe.rssUrl = "填写完整的 RSS 地址(http / https)";
if (kind === "public_bt" && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
if (kind === "rss_feed" && base && !isHttpUrl(base)) fe.baseUrl = "填完整地址,如 https://example.org";
setErrors(fe);
if (Object.keys(fe).length || !kind) return;
try {
setPhase("保存中…");
const site = await create.mutateAsync({
kind,
adapter: "generic_rss",
name: form.name.trim(),
baseUrl: base || new URL(url).origin,
...(url ? { rssUrl: url } : {}),
});
if (url) {
const feeds = await apiParsed(FeedPage, "/api/v1/feed-sources", { params: { siteId: site.id } });
for (const f of feeds.items) fetchFeed.mutate(f.id);
}
toast.success(`已添加「${site.name}」`);
onClose();
navigate(`${consolePath("sites", site.id)}?tab=rss`);
} catch (err) {
toast.error(errorMessage(err, "保存失败"));
} finally {
setPhase(null);
}
};
const items = defs.data?.items ?? [];
const def = items.find((d) => d.key === defKey);
const kinds: LoginKind[] = supportedKinds(def);
@@ -212,90 +165,33 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
</ul>
);
const feedBody = (
<form
id="site-add-feed"
noValidate
onSubmit={(e) => {
e.preventDefault();
void saveFeedSite();
}}
className="min-w-0"
>
{kind === "public_bt" && publicDefs.length ? (
<div className="mb-4">
<p className="mb-2 text-caption font-medium text-foreground">内置站点</p>
<ul className="grid gap-2 sm:grid-cols-2">
{publicDefs.map((d) => (
<li key={d.key}>
<button
type="button"
disabled={busy || !!d.siteId}
onClick={() => void addPublic(d)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
</span>
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
</button>
</li>
))}
</ul>
<p className="mt-3 mb-2 text-meta text-muted-foreground">
内置站点支持搜索和定时抓取最新资源,不需要账号。其他公开站可以在下面手动填 RSS 地址。
</p>
</div>
<div className="min-w-0">
{publicDefs.length ? (
<ul className="grid gap-2 sm:grid-cols-2">
{publicDefs.map((d) => (
<li key={d.key}>
<button
type="button"
disabled={busy || !!d.siteId}
onClick={() => void addPublic(d)}
className="flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-border/70 px-3 py-2 text-left transition-colors hover:border-foreground/40 hover:bg-accent disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent"
>
<span className="min-w-0">
<span className="block truncate text-caption font-medium text-foreground">{d.name}</span>
<span className="block truncate text-meta text-muted-foreground">{hostOf(d.baseUrl)}</span>
</span>
{d.siteId ? <StatusBadge className="shrink-0">已添加</StatusBadge> : null}
</button>
</li>
))}
</ul>
) : defs.isPending ? (
<LoadingState rows={2} />
) : null}
<FormField label="名称" htmlFor="add-feed-name" required error={errors.name}>
<Input
id="add-feed-name"
autoFocus
value={form.name}
aria-invalid={!!errors.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
disabled={busy}
/>
</FormField>
<FormField
label="RSS 地址"
htmlFor="add-feed-rss"
required={kind === "rss_feed"}
error={errors.rssUrl}
hint={kind === "public_bt" ? "可选;之后可在站点的 RSS 里添加" : "地址加密保存,页面上只显示去掉密钥参数的地址"}
>
<Input
id="add-feed-rss"
type="url"
inputMode="url"
autoComplete="off"
spellCheck={false}
placeholder="https://"
value={rssUrl}
aria-invalid={!!errors.rssUrl}
onChange={(e) => setRssUrl(e.target.value)}
disabled={busy}
/>
</FormField>
<FormField
label="网址"
htmlFor="add-feed-url"
required={kind === "public_bt"}
error={errors.baseUrl}
hint={kind === "rss_feed" ? "可选;留空时用 RSS 地址的域名" : undefined}
>
<Input
id="add-feed-url"
inputMode="url"
placeholder="https://"
value={form.baseUrl}
aria-invalid={!!errors.baseUrl}
onChange={(e) => setForm((f) => ({ ...f, baseUrl: e.target.value }))}
disabled={busy}
/>
</FormField>
<p className="text-meta text-muted-foreground">不需要账号。保存后立即抓一次 RSS。</p>
</form>
<p className="mt-3 text-meta text-muted-foreground">
不需要账号;添加后默认走代理,定时抓取最新资源,也能在资源搜索里选用。
</p>
</div>
);
const validate = () => {
@@ -562,16 +458,10 @@ export function SiteAddDialog({ onClose, initialKind }: { onClose: () => void; i
取消
</Button>
) : feedKind ? (
<>
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
<ArrowLeft />
重选类型
</Button>
<Button type="submit" form="site-add-feed" disabled={busy}>
{busy ? <LoaderCircle className="animate-spin" /> : null}
保存
</Button>
</>
<Button variant="ghost" onClick={() => setStep(0)} disabled={busy}>
<ArrowLeft />
重选类型
</Button>
) : step === 2 ? (
<>
<Button variant="ghost" onClick={() => setStep(1)} disabled={busy || !!created}>
+199 -118
View File
@@ -16,7 +16,7 @@ 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 { DataTable } from "~/components/console/data-table.tsx";
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";
@@ -180,23 +180,25 @@ function CheckButtons({ site, checks, compact }: Omit<Props, "onDialog"> & { com
<>
<Button
variant="outline"
size="sm"
size={compact ? "icon-sm" : "sm"}
disabled={h || !site.enabled}
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
aria-label="检测健康"
title={site.enabled ? "检测健康:首页连通 + 登录态,走站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
{compact ? <span className="sr-only sm:not-sr-only">检测健康</span> : "检测健康"}
{compact ? null : "检测健康"}
</Button>
<Button
variant="outline"
size="sm"
size={compact ? "icon-sm" : "sm"}
disabled={l || !hasAccount}
title={hasAccount ? "用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
aria-label="刷新登录"
title={hasAccount ? "刷新登录:用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{compact ? <span className="sr-only sm:not-sr-only">刷新登录</span> : "刷新登录"}
{compact ? null : "刷新登录"}
</Button>
</>
);
@@ -295,7 +297,47 @@ function FeedSiteCard({ site, onDialog }: Omit<Props, "checks">) {
);
}
/** 内置公开 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>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
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} />;
}
@@ -433,128 +475,167 @@ export function SiteTable({
sites,
checks,
onDialog,
variant = "pt",
}: {
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
/** pt = 带账号与流量列;public = 公开站(无账号),只列状态与资源更新 */
variant?: "pt" | "public";
}) {
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 columns: DataColumn<SiteListItem>[] =
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={[
{
key: "name",
header: "站点",
cell: (s) => (
<div className="min-w-0">
<Link to={consolePath("sites", s.id)} className="font-medium text-foreground hover:underline">
{s.name}
</Link>
<p className="truncate text-meta text-muted-foreground">{hostOf(s.baseUrl)}</p>
</div>
),
},
{
key: "kind",
header: "类型",
hideBelow: "sm",
cell: (s) => <span className="whitespace-nowrap text-muted-foreground">{kindLabel(s.kind)}</span>,
},
{
key: "account",
header: "账号",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
</div>
),
},
{
key: "login",
header: "上次登录",
hideBelow: "lg",
cell: (s) => (
<span className="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: "分享率",
hideBelow: "sm",
align: "right",
cell: (s) => <span className="tabular-nums">{ratioText(dataAccount(s.accounts)?.userData)}</span>,
},
{
key: "level",
header: "等级 · 做种",
hideBelow: "lg",
cell: (s) => {
const u = dataAccount(s.accounts)?.userData;
return u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—";
},
},
{
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="text-caption" title={healthMessage(s.lastHealth) ?? undefined}>
<StatusDot tone={h.tone} />
{h.text}
</span>
);
},
},
{
key: "snapshot",
header: "资源更新",
hideBelow: "lg",
cell: (s) => (
<span className={s.snapshotEnabled && snapshotStale(s) ? "text-warning" : "text-muted-foreground"}>
{!siteCan(s, "list")
? "只有 RSS"
: !s.snapshotEnabled
? "未开启"
: s.lastSnapshotAt
? relTime(s.lastSnapshotAt)
: "还没有"}
</span>
),
},
{
key: "actions",
header: <span className="sr-only">操作</span>,
align: "right",
cell: (s) => (
<div className="flex items-center justify-end gap-1.5">
<EnableSwitch site={s} />
{hasLogin(s) && primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
},
]}
columns={columns}
/>
);
}