Files
UHD_Media_Tool/apps/web/app/components/sites/site-overview.tsx
T
rootandClaude Opus 5.5 901b01d1a1 fix(sites): HHClub parsing, resource list layout and request restraint
HHClub (憨憨) was checked read-only against a real account (one detail
request; list, user data and health came from the product's own fetch).

Parsing fixes:
- list titles carried the site's new-torrent marker "[新]" (58 of 100
  rows; the detail page title has none); stripped with a replace filter
- refreshing a detail overwrote seeders / leechers / snatched / category
  name / H&R with null because the HHClub div detail page lacks them;
  detail values now fall back to the list snapshot (mergeDetailOverList),
  and an unknown detail promotion keeps the list promotion
- level "贵宾" is VIP (umt.vipLevels)
- sanitized minimal fixture HHANCLUB_LIST_PAGE_2026 + tests; README marks
  HHClub as verified 2026-09-29
Size, counts, category ids/names, +08:00 time and subtitle were correct.

Request restraint:
- site definitions may carry umt.defaults; merged with settings.sites
  (longer intervals, fewer pages): new settings defaultSnapshotMaxPages 1,
  defaultHealthCheckIntervalSec 6 h, defaultDetailCacheSec 24 h; default
  burst 2. HHClub: snapshot 60 min page 1 only, health 6 h, search
  concurrency 1, detail cache 24 h, 1 token / 3 s burst 2
- periodic / manual snapshots are capped to that page count; scheduled
  health checks skip sites checked within the interval; detail refresh is
  served from cache inside the window; live searches on one site queue
- every rate-limiter token is counted per site per hour
  (site_request_count, migration 0046); GET /sites/{id}/request-stats;
  site overview and resource list show requests in the last 24 hours

Resource list: two-layer rows. Main line: title + spec tags (episodes,
resolution, source, codec, HDR, group, 国语/中字, site tags) + promotion
with time left + H&R. Second line: subtitle (one line, ellipsis) and
fixed-width category / size / seed-leech-snatched / upload time
(relative, absolute on hover) columns, stale badge, inventory overlay;
folds into a card on phones.

Also aligns identity_candidate.normalized_title order in db types with
the migrations (schema drift test).

Takes effect after the stack is rebuilt (migration 0046).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-29 15:18:15 +08:00

128 lines
5.3 KiB
TypeScript

import type { SiteListItem } from "@umt/contracts";
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 { StatTile } from "~/components/console/stat-tile.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import {
ACCOUNT_OVERALL,
AUTH_KIND_LABEL,
bytes,
dataAccount,
healthLine,
healthMessage,
overallStatus,
primaryAccount,
rateLimitText,
ratioText,
} from "./site-fields.ts";
import { UserDataChart } from "./user-data-chart.tsx";
/** 站点详情"概览":账号数据磁贴 + 30 天趋势 + 状态 */
export function SiteOverview({ 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 ?? "—"} hint={u?.vip ? "VIP" : undefined} />
<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 ? "使用" : "不使用" },
]}
/>
</DetailSection>
</div>
</div>
</div>
);
}