等级里已经体现 VIP,各站 vip 判定也不一致(同为 VIP 等级,有的站是 true、有的是 null)。 卡片、列表、登录结果、站点概况去掉 VIP 显示,数据字段保留。 Co-Authored-By: Claude Opus 5.5 <[email protected]>
225 lines
8.7 KiB
TypeScript
225 lines
8.7 KiB
TypeScript
import type { SiteListItem } from "@umt/contracts";
|
||
import { Link } from "react-router";
|
||
import { useFeeds } from "~/api/feeds.ts";
|
||
import { accessSummary, useSiteRequestStats, useSiteUserData } from "~/api/sites.ts";
|
||
import { DetailSection } from "~/components/console/detail-sheet.tsx";
|
||
import { ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||
import { KvList } from "~/components/console/kv-list.tsx";
|
||
import { consolePath } from "~/components/console/nav.ts";
|
||
import { StatTile } from "~/components/console/stat-tile.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||
import {
|
||
ACCOUNT_OVERALL,
|
||
AUTH_KIND_LABEL,
|
||
bytes,
|
||
cloudflareLine,
|
||
cloudflareTechItems,
|
||
dataAccount,
|
||
hasLogin,
|
||
healthLine,
|
||
healthMessage,
|
||
healthTechItems,
|
||
kindLabel,
|
||
overallStatus,
|
||
primaryAccount,
|
||
rateLimitText,
|
||
ratioText,
|
||
siteCan,
|
||
} from "./site-fields.ts";
|
||
import { UserDataChart } from "./user-data-chart.tsx";
|
||
|
||
/** Cloudflare 状态行(CF-2);需要你验证时给原站视图入口 */
|
||
function cloudflareRow(site: SiteListItem) {
|
||
const cf = cloudflareLine(site.config);
|
||
if (!cf) return [];
|
||
const text = cf.text.replace(/^Cloudflare:/, "");
|
||
return [
|
||
{
|
||
label: "Cloudflare",
|
||
value: cf.takeover ? (
|
||
<span>
|
||
<span className="text-warning">{text}</span>
|
||
<Link className="ml-2 text-meta underline" to={`${consolePath("sites", site.id)}?tab=origin`}>
|
||
去原站视图
|
||
</Link>
|
||
</span>
|
||
) : (
|
||
text
|
||
),
|
||
},
|
||
];
|
||
}
|
||
|
||
const CAP_LABEL = [
|
||
["search", "搜索"],
|
||
["list", "列表抓取"],
|
||
["rss", "RSS"],
|
||
["detail", "详情页"],
|
||
["login", "登录"],
|
||
["userinfo", "账号数据"],
|
||
] as const;
|
||
|
||
/** 可用的获取方式:"搜索 · 列表抓取 · RSS" */
|
||
export function capabilityText(site: Pick<SiteListItem, "capabilities">): string {
|
||
const on = CAP_LABEL.filter(([c]) => siteCan(site, c)).map(([, l]) => l);
|
||
return on.length ? on.join(" · ") : "—";
|
||
}
|
||
|
||
/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态;没有登录的站点(公开 BT、自定义 RSS 源)只看 RSS */
|
||
export function SiteOverview({ site }: { site: SiteListItem }) {
|
||
return hasLogin(site) ? <AccountSiteOverview site={site} /> : <FeedSiteOverview site={site} />;
|
||
}
|
||
|
||
function FeedSiteOverview({ site }: { site: SiteListItem }) {
|
||
const feeds = useFeeds({ siteId: site.id });
|
||
const stats = useSiteRequestStats(site.id);
|
||
const items = feeds.data?.items ?? [];
|
||
const enabled = items.filter((f) => f.enabled);
|
||
const failing = enabled.filter((f) => f.healthStatus === "failing" || f.healthStatus === "circuit_open").length;
|
||
const lastOk = items
|
||
.map((f) => f.lastSuccessAt)
|
||
.filter((x): x is string => !!x)
|
||
.sort()
|
||
.at(-1);
|
||
return (
|
||
<div className="space-y-6 pt-2">
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||
<StatTile
|
||
label="RSS"
|
||
value={feeds.data ? items.length : "—"}
|
||
hint={items.length !== enabled.length ? `${enabled.length} 个启用` : undefined}
|
||
/>
|
||
<StatTile label="抓取失败" value={feeds.data ? failing || "—" : "—"} />
|
||
<StatTile label="上次成功" value={relTime(lastOk)} />
|
||
<StatTile label="近 24 小时请求" value={stats.data ? stats.data.last24h : "—"} />
|
||
</div>
|
||
<DetailSection title="站点">
|
||
<KvList
|
||
columns={1}
|
||
items={[
|
||
{ label: "类型", value: kindLabel(site.kind) },
|
||
{ label: "获取方式", value: capabilityText(site) },
|
||
{ label: "限流", value: rateLimitText(site.rateLimit) },
|
||
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
|
||
]}
|
||
/>
|
||
</DetailSection>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountSiteOverview({ site }: { site: SiteListItem }) {
|
||
const dAcc = dataAccount(site.accounts);
|
||
const acc = primaryAccount(site.accounts);
|
||
const u = dAcc?.userData ?? null;
|
||
const history = useSiteUserData(site.id, dAcc?.id, 30);
|
||
const stats = useSiteRequestStats(site.id);
|
||
const st = ACCOUNT_OVERALL[overallStatus(site)];
|
||
const h = healthLine(site.lastHealth, site.lastHealthAt);
|
||
|
||
return (
|
||
<div className="space-y-6 pt-2">
|
||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||
<StatTile label="上传" value={bytes(u?.upload)} />
|
||
<StatTile label="下载" value={bytes(u?.download)} />
|
||
<StatTile label="分享率" value={ratioText(u)} />
|
||
<StatTile label="等级" value={u?.userLevel ?? "—"} />
|
||
<StatTile label="做种" value={u ? u.seeding : "—"} hint={u?.seedingSize ? bytes(u.seedingSize) : undefined} />
|
||
<StatTile
|
||
label={u?.bonus !== null && u?.bonus !== undefined ? "魔力" : "未读消息"}
|
||
value={
|
||
u
|
||
? u.bonus !== null
|
||
? u.bonus.toLocaleString("zh-CN", { maximumFractionDigits: 1 })
|
||
: u.messageUnread || "—"
|
||
: "—"
|
||
}
|
||
hint={u && u.bonus !== null && u.messageUnread ? `${u.messageUnread} 条未读` : undefined}
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid gap-6 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||
<section className="pg-slab min-w-0 rounded-xl p-4">
|
||
<div className="mb-3 flex flex-wrap items-baseline justify-between gap-2">
|
||
<h2 className="text-lead font-semibold text-foreground">近 30 天</h2>
|
||
<span className="text-meta text-muted-foreground">
|
||
{dAcc ? `账号 ${dAcc.label} · 数据 ${relTime(dAcc.userDataAt)}` : "没有账号数据"}
|
||
</span>
|
||
</div>
|
||
{history.isPending && dAcc ? (
|
||
<LoadingState rows={4} />
|
||
) : history.error ? (
|
||
<ErrorState plain title="读取历史失败" error={history.error} onRetry={() => void history.refetch()} />
|
||
) : (
|
||
<UserDataChart points={history.data?.items ?? []} />
|
||
)}
|
||
</section>
|
||
|
||
<div className="space-y-6">
|
||
<DetailSection title="账号">
|
||
<KvList
|
||
columns={1}
|
||
items={[
|
||
{ label: "状态", value: <StatusBadge tone={st.tone}>{st.label}</StatusBadge> },
|
||
{ label: "凭证", value: acc ? AUTH_KIND_LABEL[acc.authKind] : "未配置" },
|
||
{ label: "用户名", value: u?.username ?? acc?.loginName },
|
||
{ label: "上次登录", value: relTime(acc?.lastLoginAt) },
|
||
...(acc?.lastError && acc.status !== "active"
|
||
? [{ label: "最近错误", value: <span className="text-warning">{acc.lastError.message}</span> }]
|
||
: []),
|
||
]}
|
||
/>
|
||
</DetailSection>
|
||
<DetailSection title="站点">
|
||
<KvList
|
||
columns={1}
|
||
items={[
|
||
{
|
||
label: "健康检测",
|
||
value: (
|
||
<span>
|
||
{h.text}
|
||
{healthMessage(site.lastHealth) ? (
|
||
<span className="block text-meta text-muted-foreground">{healthMessage(site.lastHealth)}</span>
|
||
) : null}
|
||
</span>
|
||
),
|
||
},
|
||
{
|
||
label: "抓取",
|
||
value: site.snapshotEnabled
|
||
? `${site.lastSnapshotAt ? relTime(site.lastSnapshotAt) : "还没抓取过"} · ${site.snapshotIntervalSec ? `每 ${Math.round(site.snapshotIntervalSec / 60)} 分钟` : "默认周期"}`
|
||
: "未开启",
|
||
},
|
||
{ label: "限流", value: rateLimitText(site.rateLimit) },
|
||
{
|
||
label: "近 24 小时请求",
|
||
value: stats.data ? (
|
||
<span>
|
||
{stats.data.last24h} 次
|
||
<span className="block text-meta text-muted-foreground">{accessSummary(stats.data)}</span>
|
||
</span>
|
||
) : (
|
||
"—"
|
||
),
|
||
},
|
||
{ label: "代理", value: site.proxy ? "使用" : "不使用" },
|
||
...cloudflareRow(site),
|
||
...(site.kind && site.kind !== "pt" ? [{ label: "类型", value: kindLabel(site.kind) }] : []),
|
||
{ label: "获取方式", value: capabilityText(site) },
|
||
]}
|
||
/>
|
||
<TechInfo
|
||
className="mt-2"
|
||
columns={1}
|
||
items={[...healthTechItems(site.lastHealth), ...cloudflareTechItems(site.config)]}
|
||
/>
|
||
</DetailSection>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|