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; 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 ( {withLabel ? : null} toggle.mutate( { id: site.id, enabled: v }, { onSuccess: () => toast.success(v ? `已启用 ${site.name}` : `已停用 ${site.name}`), onError: (e) => toast.error(errorMessage(e)), }, ) } /> ); } /** 默认参与搜索:搜资源时「默认站点」集合 */ function SearchDefaultSwitch({ site }: { site: SiteListItem }) { const update = useUpdateSite(); const on = site.searchDefault !== false; return ( update.mutate( { id: site.id, body: { searchDefault: v } }, { onSuccess: () => toast.success(v ? `${site.name} 默认参与搜索` : `${site.name} 不再默认参与搜索`), onError: (e) => toast.error(errorMessage(e)), }, ) } /> ); } /** "更多"菜单:更新凭证、编辑、快照、启停、删除 */ 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 } = {}, ) => ( ); return ( {hasLogin(site) ? item(, updateCredLabel(site), () => onDialog({ kind: "credentials", site })) : null} {item(, "编辑站点", () => onDialog({ kind: "edit", site }))} {siteCan(site, "list") ? item( , "抓取最新资源", () => snap.mutate( { id: site.id }, { onSuccess: () => toast.success("已开始抓取"), onError: (e) => toast.error(errorMessage(e)) }, ), { disabled: !site.enabled }, ) : null} {item(, 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)), }, ), )}
{item(, "删除站点", () => onDialog({ kind: "delete", site }), { danger: true })} ); } function CheckButtons({ site, checks, compact }: Omit & { 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 ( <> ); } function AccountBadges({ site }: { site: SiteListItem }) { if (!hasLogin(site)) return null; const st = ACCOUNT_OVERALL[overallStatus(site)]; // 正常不标;登录方式不做徽章(DEAI-2) return st.tone === "success" ? null : 账号{st.label}; } function Stat({ label, children, className }: { label: string; children: ReactNode; className?: string }) { return (
{label}
{children}
); } /** 卡片头:名称、域名(外链)、启停;两种卡片共用,保证下方内容同一基线 */ function SiteCardHeader({ site }: { site: SiteListItem }) { return (
); } /** 没有登录的站点(公开 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) { const f = useSiteFeeds(site.id); return (
{kindLabel(site.kind)} {f.failing ? {f.failing} 个 RSS 抓取失败 : null}
RSS
{!f.loaded ? "—" : f.count ? `${f.count} 个${f.enabled !== f.count ? `,${f.enabled} 个启用` : ""}` : "还没有"}
上次成功
{relTime(f.lastOk)}
); } /** 内置公开 BT 站(专用适配器,无账号):出口、状态、资源更新,不走 RSS */ function PublicSiteCard({ site, onDialog }: Omit) { const health = healthLine(site.lastHealth, site.lastHealthAt); const stale = site.snapshotEnabled && snapshotStale(site); return (
出口
{site.proxy ? "代理" : "直连"}
状态
{health.text}
资源更新
{!site.snapshotEnabled ? "未开启" : site.lastSnapshotAt ? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}` : "还没抓取过"}
{siteCan(site, "search") ? : }
); } /** 字幕站(SubHD、字幕库):出口与状态;搜索 / 下载在作品页「补字幕」里 */ function SubtitleSiteCard({ site, checks, onDialog }: Props) { const health = healthLine(site.lastHealth, site.lastHealthAt); const busy = checks.isBusy(site.id, "health"); return (
出口
{site.proxy ? "代理" : "直连"}
状态
{health.text}

在作品页「补字幕」里搜索、下载。

); } export function SiteCard({ site, checks, onDialog }: Props) { if (site.kind === "subtitle") return ; if (!hasLogin(site) && siteCan(site, "list")) return ; if (!hasLogin(site)) return ; return ; } 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 (
{site.kind && site.kind !== "pt" ? {kindLabel(site.kind)} : null} {circuit ? {circuit} : null} {cf ? ( cf.takeover ? ( {cf.text} ) : ( {cf.text} ) ) : null}
{lastErr ? (

{loginFailureText(lastErr.reason, lastErr.message, acc?.authKind).text}

) : null}
{u ? ( <>
{bytes(u.upload)} {bytes(u.download)} {ratioText(u)} {u.userLevel ?? "—"} {u.seeding} {u.seedingSize ? · {bytes(u.seedingSize)} : null} {u.bonus !== null ? ( {u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })} ) : ( {u.messageUnread || "—"} )}

{u.username ?? ""} {u.messageUnread > 0 && u.bonus !== null ? `${u.messageUnread} 条未读 · ` : ""}数据{" "} {relTime(dAcc?.userDataAt)}

) : (

{acc ? "还没有用户数据。刷新登录后显示" : "还没有凭证。填写凭证后显示账号数据"}

)}
上次登录
{relTime(acc?.lastLoginAt)}
状态
{health.text}
资源更新
{!site.snapshotEnabled ? "未开启" : site.lastSnapshotAt ? `${relTime(site.lastSnapshotAt)}${stale ? " · 已过期" : ""}` : "还没抓取过"}
{siteCan(site, "search") ? : } {/* 操作与「更多」放一组整体换行,窄卡片里「⋯」不会单独掉到下一行 */}
{acc ? ( ) : ( )}
); } function StatusDot({ tone }: { tone: "success" | "warning" | "destructive" | "neutral" }) { return ( ); } /** 列表视图:同字段,窄屏隐藏次要列 */ 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 = { key: "name", header: "站点", cell: (s) => (
{s.name}

{hostOf(s.baseUrl)}

), }; const snapshot: DataColumn = { key: "snapshot", header: "资源更新", hideBelow: "md", cell: (s) => ( {!siteCan(s, "list") ? "只有 RSS" : !s.snapshotEnabled ? "未开启" : s.lastSnapshotAt ? relTime(s.lastSnapshotAt) : "还没有"} ), }; const actions: DataColumn = { key: "actions", header: 操作, align: "right", className: "w-px", cell: (s) => (
{hasLogin(s) && primaryAccount(s.accounts) ? : null}
), }; const health: DataColumn = { key: "health", header: "状态", hideBelow: "sm", cell: (s) => { const h = healthLine(s.lastHealth, s.lastHealthAt); return ( {h.text} ); }, }; const columns: DataColumn[] = variant === "subtitle" ? [ name, { key: "proxy", header: "出口", hideBelow: "sm", cell: (s) => {s.proxy ? "代理" : "直连"}, }, health, actions, ] : variant === "public" ? [ name, { key: "proxy", header: "出口", hideBelow: "sm", cell: (s) => {s.proxy ? "代理" : "直连"}, }, { key: "search", header: "资源搜索", hideBelow: "sm", cell: (s) => ( {s.searchDefault ? "默认参与" : "手动选"} ), }, snapshot, actions, ] : [ name, { key: "account", header: "账号", hideBelow: "xl", cell: (s) => (
), }, { key: "login", header: "上次登录", hideBelow: "xl", cell: (s) => ( {relTime(primaryAccount(s.accounts)?.lastLoginAt)} ), }, { key: "traffic", header: "上传 / 下载", hideBelow: "md", cell: (s) => { const u = dataAccount(s.accounts)?.userData; return u ? ( {bytes(u.upload)} / {bytes(u.download)} ) : ( "—" ); }, }, { key: "ratio", header: "分享率", align: "right", cell: (s) => ( {ratioText(dataAccount(s.accounts)?.userData)} ), }, { key: "level", header: "等级 · 做种", hideBelow: "lg", cell: (s) => { const u = dataAccount(s.accounts)?.userData; const t = u ? `${u.userLevel ?? "—"} · ${u.seeding}` : "—"; return ( {t} ); }, }, { key: "health", header: "状态", hideBelow: "md", cell: (s) => { if (!hasLogin(s)) return —; const h = healthLine(s.lastHealth, s.lastHealthAt); return ( {h.text} ); }, }, snapshot, actions, ]; // 站点数量不多:分组表格不分页 return ( 50} paginationKey={params.toString()} rowKey={(r) => r.id} columns={columns} /> ); }