Files
UHD_Media_Tool/apps/web/app/routes/console/sites.$siteId.tsx
T
rootandClaude Opus 5.5 6e9e8efec2 fix(sites): HHClub parsing, resource list layout and request restraint (re-landed)
Re-lands 901b01d, which was reverted wholesale by 28974d1 (BR-2 committed
from the shared index). The agent UI files 901b01d had rolled back are
left at their AGUI-2 versions; contracts, routes, db types and the runbook
part already reached main through later commits, so this carries the rest.

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 16:00:34 +08:00

517 lines
21 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 { ResolutionStatus, WorkbenchRelease } from "@umt/contracts";
import {
Activity,
ArrowLeft,
Bot,
Camera,
Globe,
LoaderCircle,
Plus,
Radar,
RefreshCw,
SearchX,
TriangleAlert,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams, useSearchParams } from "react-router";
import { toast } from "sonner";
import { useInventory } from "~/api/inventory.ts";
import { type ReleaseFilters, useSiteReleases } from "~/api/releases.ts";
import { accessSummary, snapshotStale, useSiteRequestStats, useSites, useSnapshotSite } from "~/api/sites.ts";
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
import { SiteOriginPanel } from "~/components/browser/desktop-view.tsx";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
import { ACCOUNT_OVERALL, healthLine, hostOf, overallStatus, primaryAccount } from "~/components/sites/site-fields.ts";
import { SiteOverview } from "~/components/sites/site-overview.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
import { FeedTable } from "~/components/workbench/feed-table.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { categoryLabel, circuitLabel, hrOf, isFree, relTime } from "~/components/workbench/release-fields.ts";
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
const PROMOS = [
{ value: "free", label: "免费" },
{ value: "2xfree", label: "2x 免费" },
{ value: "50%", label: "50%" },
{ value: "30%", label: "30%" },
{ value: "2x", label: "2x 上传" },
];
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "待识别" },
{ value: "ambiguous", label: "有歧义" },
{ value: "unidentifiable", label: "无法识别" },
];
const SIZE = [
{ value: "lt5", label: "< 5 GB" },
{ value: "5to20", label: "5–20 GB" },
{ value: "20to60", label: "20–60 GB" },
{ value: "gt60", label: "> 60 GB" },
];
const AGE = [
{ value: "1", label: "24 小时内" },
{ value: "7", label: "7 天内" },
{ value: "30", label: "30 天内" },
];
const GB = 1024 ** 3;
/** 服务端不支持的维度(H&R/做种/大小/上传时间)在已加载结果里筛选,界面上写明 */
type LocalFilters = { hr: string; seeding: string; size: string; age: string };
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
if (f.hr === "no" && hrOf(r) !== "no") return false;
if (f.hr === "yes" && hrOf(r) !== "yes") return false;
if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false;
if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false;
if (f.size) {
const s = r.sizeBytes;
if (s === null) return false;
if (f.size === "lt5" && s >= 5 * GB) return false;
if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false;
if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false;
if (f.size === "gt60" && s < 60 * GB) return false;
}
if (f.age) {
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
}
return true;
}
function useDebounced<T>(value: T, ms = 400) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), ms);
return () => clearTimeout(t);
}, [value, ms]);
return v;
}
const TABS = ["overview", "resources", "origin", "snapshots", "accounts"] as const;
/** 旧页签(整理视图)落到"资源" */
const LEGACY_TAB: Record<string, string> = { organized: "resources" };
export const meta = () => [{ title: "站点 · UHD 媒体自动化控制台" }];
export default function SiteWorkbenchPage() {
const { siteId = "" } = useParams();
const [params, setParams] = useSearchParams();
const navigate = useNavigate();
const rawTab = params.get("tab") ?? "";
const mapped = LEGACY_TAB[rawTab] ?? rawTab;
const tab = (TABS as readonly string[]).includes(mapped) ? mapped : "overview";
const [creatingFeed, setCreatingFeed] = useState(false);
const [dialog, setDialog] = useState<SiteDialog | null>(null);
const checks = useSiteChecks(setDialog);
const sites = useSites();
const snap = useSnapshotSite();
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
const [category, setCategory] = useState("");
const [promotion, setPromotion] = useState("");
const [resolution, setResolution] = useState("");
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
const [live, setLive] = useState(false);
const [openId, setOpenId] = useState<string | null>(null);
const filters: ReleaseFilters = {
q: q || undefined,
category: category || undefined,
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
};
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
const pages = releases.data?.pages ?? [];
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const coverage = pages[0]?.coverage;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const localActive = Object.values(local).some(Boolean);
const inventory = useInventory(all.flatMap((r) => (r.entity ? [r.entity.id] : [])));
const categories = useMemo(() => {
const m = new Map<string, string>();
for (const r of all) if (r.categoryRaw) m.set(r.categoryRaw, categoryLabel(r) ?? r.categoryRaw);
if (category && !m.has(category)) m.set(category, category);
return [...m].map(([value, label]) => ({ value, label }));
}, [all, category]);
const s = sites.data?.items.find((x) => x.id === siteId);
const refreshFailed = !!releases.error && !!releases.data;
// 刷新失败时整页数据都标过期;否则早于 2 个快照周期确认的条目标过期
const staleBefore = refreshFailed ? now + 1 : s ? now - (s.snapshotIntervalSec ?? 3600) * 2000 : null;
const selected = all.find((r) => r.id === openId) ?? null;
const freeCount = all.filter(isFree).length;
const reqStats = useSiteRequestStats(tab === "resources" ? siteId : undefined);
const reset = () => {
setQInput("");
setCategory("");
setPromotion("");
setResolution("");
setLocal({ hr: "", seeding: "", size: "", age: "" });
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
if (sites.error && !sites.data) return <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
return (
<EmptyState
icon={Globe}
title="站点不存在或已删除"
description="回到站点页查看现有站点"
action={
<Button asChild size="sm" variant="outline">
<Link to={consolePath("sites")}>
<ArrowLeft />
返回站点
</Link>
</Button>
}
/>
);
const account = s ? ACCOUNT_OVERALL[overallStatus(s)] : null;
const health = s ? healthLine(s.lastHealth, s.lastHealthAt) : null;
const circuit = s ? circuitLabel(s) : null;
const hasAccount = !!s?.accounts?.some((a) => a.enabled && a.authKind !== "passkey");
return (
<>
<PageHeader
title={s?.name ?? "站点"}
description={
s ? (
<span className="flex flex-wrap items-center gap-1.5">
<a
href={s.baseUrl}
target="_blank"
rel="noreferrer noopener"
className="text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{hostOf(s.baseUrl)}
</a>
{account ? <StatusBadge tone={account.tone}>账号{account.label}</StatusBadge> : null}
{health ? <StatusBadge tone={health.tone}>健康 {health.text}</StatusBadge> : null}
{!s.enabled ? <StatusBadge tone="warning">站点已停用,只显示历史数据</StatusBadge> : null}
{circuit ? <StatusBadge tone="warning">{circuit}</StatusBadge> : null}
{s.snapshotEnabled && snapshotStale(s) ? <StatusBadge tone="warning">快照过期</StatusBadge> : null}
</span>
) : (
"加载中…"
)
}
actions={
<>
<Button asChild variant="ghost" size="sm">
<Link to={consolePath("sites")}>
<ArrowLeft />
站点
</Link>
</Button>
{s ? (
<>
<Button
variant="outline"
size="sm"
disabled={!s.enabled || checks.isBusy(s.id, "health")}
onClick={() => void checks.checkHealth(s)}
>
{checks.isBusy(s.id, "health") ? <LoaderCircle className="animate-spin" /> : <Activity />}
<span className="sr-only sm:not-sr-only">检测健康</span>
</Button>
<Button
variant="outline"
size="sm"
disabled={!hasAccount || checks.isBusy(s.id, "login")}
title={hasAccount ? undefined : "先填写凭证"}
onClick={() => void checks.refreshLogin(s, primaryAccount(s.accounts))}
>
{checks.isBusy(s.id, "login") ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
<span className="sr-only sm:not-sr-only">刷新登录</span>
</Button>
<SiteMoreMenu site={s} onDialog={setDialog} />
</>
) : null}
<Button
variant="ghost"
size="sm"
onClick={() => openAgentDrawer({ label: `站点:${s?.name ?? siteId}`, siteId })}
>
<Bot />
<span className="sr-only sm:not-sr-only">问 Agent</span>
</Button>
</>
}
/>
<Tabs
value={tab}
onValueChange={(v) =>
setParams(
(p) => {
if (v === "overview") p.delete("tab");
else p.set("tab", v);
return p;
},
{ replace: true },
)
}
>
<TabsList aria-label="站点分节">
<TabsTrigger value="overview">概览</TabsTrigger>
<TabsTrigger value="resources">资源</TabsTrigger>
<TabsTrigger value="origin">原站视图</TabsTrigger>
<TabsTrigger value="snapshots">快照与 Feed</TabsTrigger>
<TabsTrigger value="accounts">账号</TabsTrigger>
</TabsList>
<TabsContent value="overview">{s ? <SiteOverview site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="resources">
<FilterBar
search={{ value: qInput, onChange: setQInput, placeholder: "搜索标题 / 副标题 / 站点 ID" }}
onReset={anyFilter ? reset : undefined}
actions={
<>
<Button
variant="outline"
size="sm"
disabled={!s?.enabled || snap.isPending}
title={
reqStats.data
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
: "抓取站点列表(受站点限流)"
}
onClick={() =>
snap.mutate(
{ id: siteId },
{
onSuccess: () => toast.success("快照已排队"),
onError: (e) => toast.error(errorMessage(e)),
},
)
}
>
<Camera />
快照
</Button>
<Button
size="sm"
disabled={!q || !s?.enabled || (live && releases.isFetching)}
title={q ? "跳过本地快照,直接在站点搜索" : "先输入搜索词"}
onClick={() => {
if (live) void releases.refetch();
else setLive(true);
}}
>
<Radar className={live && releases.isFetching ? "animate-pulse" : undefined} />
实时补查
</Button>
</>
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="H&R"
value={local.hr}
onChange={(v) => setLocal((l) => ({ ...l, hr: v }))}
options={[
{ value: "no", label: "无 H&R" },
{ value: "yes", label: "有 H&R" },
]}
/>
<FilterSelect
label="做种"
value={local.seeding}
onChange={(v) => setLocal((l) => ({ ...l, seeding: v }))}
options={[
{ value: "alive", label: "有人做种" },
{ value: "hot", label: "≥ 20 做种" },
]}
/>
<FilterSelect
label="大小"
value={local.size}
onChange={(v) => setLocal((l) => ({ ...l, size: v }))}
options={SIZE}
/>
<FilterSelect
label="上传"
value={local.age}
onChange={(v) => setLocal((l) => ({ ...l, age: v }))}
options={AGE}
/>
</FilterBar>
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{releases.isPending ? (
<LoadingState rows={6} className="p-4" />
) : releases.error && !releases.data ? (
<ErrorState plain title="读取资源失败" error={releases.error} onRetry={() => void releases.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length ? SearchX : Globe}
title={
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
}
description={
all.length
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (
<span className="ml-auto" title={accessSummary(reqStats.data)}>
近 24 小时请求站点 {reqStats.data.last24h} 次
</span>
) : null}
{releases.isFetching && !releases.isFetchingNextPage ? <span>刷新中…</span> : null}
</div>
<ul className="divide-y divide-border/60">
{rows.map((r) => (
<ReleaseRow
key={r.id}
r={r}
entry={r.entity ? inventory.data?.get(r.entity.id) : undefined}
inventoryLoading={inventory.isLoading}
staleBefore={staleBefore}
selected={openId === r.id}
onOpen={() => setOpenId(r.id)}
/>
))}
</ul>
</>
)}
{releases.hasNextPage ? (
<div className="flex justify-center border-t border-border/60 px-4 py-3">
<Button
variant="outline"
size="sm"
onClick={() => void releases.fetchNextPage()}
disabled={releases.isFetchingNextPage}
>
{releases.isFetchingNextPage ? "加载中…" : "加载更多"}
</Button>
</div>
) : null}
</div>
</TabsContent>
<TabsContent value="origin">{s ? <SiteOriginPanel site={s} /> : <LoadingState rows={4} />}</TabsContent>
<TabsContent value="snapshots">
<div className="space-y-8">
{s ? <SnapshotPanel site={s} /> : <LoadingState rows={3} />}
<DetailSection
title="Feed"
actions={
<Button size="sm" variant="outline" onClick={() => setCreatingFeed(true)}>
<Plus />
新建 Feed
</Button>
}
>
<FeedTable siteId={siteId} creating={creatingFeed} onCreatingChange={setCreatingFeed} />
</DetailSection>
</div>
</TabsContent>
<TabsContent value="accounts">
{s ? <SiteAccountsTab site={s} checks={checks} onDialog={setDialog} /> : <LoadingState rows={3} />}
</TabsContent>
</Tabs>
<SiteDialogHost
dialog={dialog}
onClose={() => setDialog(null)}
onDeleted={() => navigate(consolePath("sites"))}
/>
<ReleaseDetailSheet
siteId={siteId}
release={selected}
entry={selected?.entity ? inventory.data?.get(selected.entity.id) : undefined}
onClose={() => setOpenId(null)}
/>
</>
);
}
function CoverageBanner({
coverage,
refreshError,
}: {
coverage: import("@umt/contracts").WorkbenchCoverage | undefined;
refreshError: unknown;
}) {
const notes: { tone: "warning" | "neutral" | "success"; text: string }[] = [];
if (refreshError) notes.push({ tone: "warning", text: `刷新失败,显示上次数据:${errorMessage(refreshError)}` });
if (coverage) {
if (!coverage.siteEnabled) notes.push({ tone: "warning", text: "站点已停用,只显示历史数据" });
if (coverage.stale)
notes.push({
tone: "warning",
text: coverage.lastSnapshotAt
? `快照已过期(上次 ${relTime(coverage.lastSnapshotAt)})`
: "没有快照,列表只来自 Feed 和实时补查",
});
if (coverage.liveError) notes.push({ tone: "warning", text: `实时补查失败:${coverage.liveError}` });
else if (coverage.live)
notes.push({ tone: "success", text: coverage.coalesced ? "已合并到进行中的同一搜索" : "已实时补查" });
}
if (!notes.length) return null;
return (
<div className="mb-3 space-y-1.5">
{notes.map((n) => (
<p
key={n.text}
className={
n.tone === "warning"
? "flex items-start gap-2 rounded-lg border border-warning/40 bg-warning/10 px-3 py-2 text-caption text-warning"
: n.tone === "success"
? "flex items-start gap-2 rounded-lg border border-success/30 bg-success/10 px-3 py-2 text-caption text-success"
: "rounded-lg border border-border px-3 py-2 text-caption text-muted-foreground"
}
>
{n.tone === "warning" ? <TriangleAlert className="mt-0.5 h-3.5 w-3.5 shrink-0" /> : null}
<span className="min-w-0 break-words">{n.text}</span>
</p>
))}
</div>
);
}