- 资源 → 站点:卡片 / 列表切换(URL ?view= + localStorage 记住),筛选全部 / 已启用 / 需处理, 按分享率 / 上传 / 上次登录排序;卡片显示账号状态、凭证类型、上次登录、上传 / 下载 / 分享率、 等级 / VIP、做种、健康检测结果与时间、快照、限流 - 操作:检测健康、刷新登录(wait:true 同步结果)、更新凭证、编辑(限流 / 快照 / 代理)、启停、 删除(确认框列出影响) - 添加站点向导:按适配器分组选 23 个定义(未实测标注)→ 名称 / 域名覆盖 / 凭证三选一 (馒头默认 API Key)→ 保存并测试(刷新登录 + 健康检测);验证码 / 二次验证提示改用 Cookie 并链接待处理 - 站点详情:概览(用户数据磁贴 + 30 天上传 / 下载与分享率折线,recharts,无彩)/ 资源 / 快照与 Feed / 账号(凭证管理、Passkey、登录历史) - 设置"站点与账号"改为"站点默认"(限流、快照周期、UA、代理、做种页数) - 侧栏改名"站点";smoke 补站点路由;依赖新增 recharts Co-Authored-By: Claude Opus 5.5 <[email protected]>
456 lines
16 KiB
TypeScript
456 lines
16 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 } 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>
|
|
);
|
|
}
|
|
|
|
/** "更多"菜单:更新凭证、编辑、快照、启停、删除 */
|
|
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">
|
|
{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>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
);
|
|
}
|