Files
UHD_Media_Tool/apps/web/app/components/sites/site-card.tsx
T

482 lines
17 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 } from "react-router";
import { toast } from "sonner";
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,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
healthMessage,
hostOf,
loginFailureText,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} 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">
{item(<KeyRound />, updateCredLabel(site), () => onDialog({ kind: "credentials", site }))}
{item(<Pencil />, "编辑站点", () => onDialog({ kind: "edit", site }))}
{item(
<Camera />,
"抓取快照",
() =>
snap.mutate(
{ id: site.id },
{ onSuccess: () => toast.success("快照已排队"), onError: (e) => toast.error(errorMessage(e)) },
),
{ disabled: !site.enabled },
)}
{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 }) {
const st = ACCOUNT_OVERALL[overallStatus(site)];
const acc = primaryAccount(site.accounts);
const hasPasskey = site.accounts?.some((a) => a.hasPasskey || a.authKind === "passkey");
return (
<>
<StatusBadge tone={st.tone}>账号{st.label}</StatusBadge>
{acc && acc.authKind !== "passkey" ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">
{AUTH_KIND_LABEL[acc.authKind]}
</span>
) : null}
{hasPasskey ? (
<span className="rounded-full border border-border px-2 py-0.5 text-meta text-muted-foreground">Passkey</span>
) : null}
</>
);
}
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>
);
}
export function SiteCard({ 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 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")}>
<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="inline-flex max-w-full items-center gap-1 truncate 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>
<div className="mt-3 flex flex-wrap items-center gap-1.5">
<AccountBadges site={site} />
{u?.vip ? <StatusBadge tone="neutral">VIP</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</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>
<div className="min-w-0">
<dt className="text-meta text-muted-foreground">限流</dt>
<dd className="truncate text-foreground">{rateLimitText(site.rateLimit)}</dd>
</div>
</dl>
<footer className="mt-4 flex flex-wrap items-center justify-end gap-2 border-t border-border/60 pt-3">
<SearchDefaultSwitch site={site} />
{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;
}) {
return (
<DataTable
rows={sites}
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: "account",
header: "账号",
cell: (s) => (
<div className="flex flex-wrap items-center gap-1">
<AccountBadges site={s} />
{dataAccount(s.accounts)?.userData?.vip ? <StatusBadge>VIP</StatusBadge> : null}
</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) => {
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"}>
{!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} />
{primaryAccount(s.accounts) ? <CheckButtons site={s} checks={checks} compact /> : null}
<SiteMoreMenu site={s} onDialog={onDialog} />
</div>
),
},
]}
/>
);
}