Files
UHD_Media_Tool/apps/web/app/components/sites/site-card.tsx
T
rootandClaude Opus 5.5 147c6b1947 fix(sites): 不再单独显示 VIP 标签
等级里已经体现 VIP,各站 vip 判定也不一致(同为 VIP 等级,有的站是 true、有的是 null)。
卡片、列表、登录结果、站点概况去掉 VIP 显示,数据字段保留。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-02 18:20:52 +08:00

561 lines
20 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 { 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="sm"
disabled={h || !site.enabled}
title={site.enabled ? "首页连通 + 登录态,走站点限流" : "站点已停用"}
onClick={() => void checks.checkHealth(site)}
>
{h ? <LoaderCircle className="animate-spin" /> : <Activity />}
{compact ? <span className="sr-only sm:not-sr-only">检测健康</span> : "检测健康"}
</Button>
<Button
variant="outline"
size="sm"
disabled={l || !hasAccount}
title={hasAccount ? "用凭证刷新登录状态并拉取用户数据" : "先填写凭证"}
onClick={() => void checks.refreshLogin(site)}
>
{l ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
{compact ? <span className="sr-only sm:not-sr-only">刷新登录</span> : "刷新登录"}
</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>
);
}
export function SiteCard({ site, checks, onDialog }: Props) {
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" />}
{acc ? (
<CheckButtons site={site} checks={checks} />
) : (
<Button size="sm" onClick={() => onDialog({ kind: "credentials", site })}>
<KeyRound />
填写凭证
</Button>
)}
<SiteMoreMenu site={site} onDialog={onDialog} />
</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,
}: {
sites: SiteListItem[];
checks: Checks;
onDialog: (d: SiteDialog) => void;
}) {
const [params] = useSearchParams();
return (
<DataTable
rows={sites}
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>
),
},
]}
/>
);
}