- 顶栏搜索移进右侧玻璃胶囊;输入框改淡玻璃(边线 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
712 lines
26 KiB
TypeScript
712 lines
26 KiB
TypeScript
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}
|
||
/>
|
||
);
|
||
}
|