Files
UHD_Media_Tool/apps/web/app/components/sites/site-overview.tsx
T
rootandClaude Opus 5.5 ec8b224482 feat(web): 站点页技术信息里显示最近的失败页面样本
站点概览「站点」一栏的技术信息里列出最近 5 份失败样本(搜索 / 列表 / 详情 · 人机验证 / 页面结构无法识别 ·
HTTP 状态),展开看脱敏地址、错误、命中规则、关键响应头和页面正文,可下载文本;没有样本不显示。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 15:43:15 +08:00

229 lines
9.0 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 { Link } from "react-router";
import { useFeeds } from "~/api/feeds.ts";
import { accessSummary, useSiteFailureSamples, 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 { SiteFailureSamples } from "./site-failure-samples.tsx";
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 samples = useSiteFailureSamples(site.id).data?.items ?? [];
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)]}
>
{samples.length ? <SiteFailureSamples siteId={site.id} items={samples} /> : null}
</TechInfo>
</DetailSection>
</div>
</div>
</div>
);
}