fix(sites): HHClub parsing, resource list layout and request restraint (re-landed)
Re-lands901b01d, which was reverted wholesale by28974d1(BR-2 committed from the shared index). The agent UI files901b01dhad 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]>
This commit is contained in:
1 parent
99e335d13c
commit
6e9e8efec2
22 files changed
+753
-76
No files matched your search
@@ -12,6 +12,7 @@ import {
|
||||
SiteHealthResult,
|
||||
SiteListItem,
|
||||
SiteRefreshLoginAccepted,
|
||||
SiteRequestStats,
|
||||
SiteSnapshot,
|
||||
type SiteUpdate,
|
||||
SiteUserDataPoint,
|
||||
@@ -33,6 +34,7 @@ export const siteKeys = {
|
||||
one: (id: string) => ["sites", "one", id] as const,
|
||||
accounts: (siteId?: string) => ["sites", "accounts", siteId ?? "all"] as const,
|
||||
snapshots: (siteId: string) => ["sites", "snapshots", siteId] as const,
|
||||
requestStats: (siteId: string) => ["sites", "request-stats", siteId] as const,
|
||||
definitions: () => ["sites", "definitions"] as const,
|
||||
userData: (siteId: string, accountId?: string, days = 30) =>
|
||||
["sites", "user-data", siteId, accountId ?? "first", days] as const,
|
||||
@@ -88,6 +90,28 @@ export function useSiteSnapshots(siteId: string | undefined, limit = 20) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 最近 24 小时对该站的请求次数与生效的访问默认(每分钟刷新) */
|
||||
export function useSiteRequestStats(siteId: string | undefined) {
|
||||
return useQuery({
|
||||
queryKey: siteKeys.requestStats(siteId ?? ""),
|
||||
enabled: !!siteId,
|
||||
refetchInterval: 60 * TTL_SECOND,
|
||||
queryFn: ({ signal }) =>
|
||||
apiParsed(SiteRequestStats, "/api/v1/sites/:id/request-stats", { pathParams: { id: siteId ?? "" }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
/** 访问默认的一句话说明:"快照每 60 分钟 1 页 · 健康检测每 6 小时 · 详情缓存 24 小时" */
|
||||
export function accessSummary(s: SiteRequestStats): string {
|
||||
const dur = (sec: number) => (sec % 3600 === 0 ? `${sec / 3600} 小时` : `${Math.round(sec / 60)} 分钟`);
|
||||
const parts = [
|
||||
`快照每 ${dur(s.snapshotIntervalSec)} ${s.access.snapshotMaxPages} 页`,
|
||||
`健康检测每 ${dur(s.access.healthCheckIntervalSec)}`,
|
||||
];
|
||||
if (s.access.detailCacheSec > 0) parts.push(`详情缓存 ${dur(s.access.detailCacheSec)}`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
/** 启用 / 停用站点 */
|
||||
export function useToggleSite() {
|
||||
const qc = useQueryClient();
|
||||
|
||||
@@ -27,6 +27,24 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
kind: "number",
|
||||
hint: "站点开了定时快照但没设周期时使用;5–10080",
|
||||
},
|
||||
{
|
||||
name: "sites.defaultSnapshotMaxPages",
|
||||
label: "快照页数",
|
||||
kind: "number",
|
||||
hint: "定时和手动快照最多抓几页列表;站点定义更少时按站点;1–20",
|
||||
},
|
||||
{
|
||||
name: "sites.defaultHealthCheckIntervalSec",
|
||||
label: "健康检测间隔(小时)",
|
||||
kind: "number",
|
||||
hint: "同一站点两次定时检测至少隔这么久;站点定义更长时按站点",
|
||||
},
|
||||
{
|
||||
name: "sites.defaultDetailCacheSec",
|
||||
label: "详情缓存(小时)",
|
||||
kind: "number",
|
||||
hint: "期内不重复抓同一条资源详情;0 为不缓存",
|
||||
},
|
||||
{
|
||||
name: "sites.userDataMaxSeedingPages",
|
||||
label: "做种列表页数上限",
|
||||
@@ -54,6 +72,9 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
"sites.defaultRateLimit.concurrency": n(s.sites.defaultRateLimit.concurrency),
|
||||
"sites.defaultSnapshotIntervalSec": n(Math.round(s.sites.defaultSnapshotIntervalSec / 60)),
|
||||
"sites.userDataMaxSeedingPages": n(s.sites.userDataMaxSeedingPages),
|
||||
"sites.defaultSnapshotMaxPages": n(s.sites.defaultSnapshotMaxPages),
|
||||
"sites.defaultHealthCheckIntervalSec": n(s.sites.defaultHealthCheckIntervalSec / 3600),
|
||||
"sites.defaultDetailCacheSec": n(s.sites.defaultDetailCacheSec / 3600),
|
||||
"sites.userAgent": s.sites.userAgent,
|
||||
"sites.proxyUrl": s.sites.proxyUrl ?? "",
|
||||
}),
|
||||
@@ -61,6 +82,8 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
const min = num(v["sites.defaultSnapshotIntervalSec"]);
|
||||
const rpm = num(v["sites.defaultRateLimit.requestsPerMinute"]);
|
||||
const concurrency = num(v["sites.defaultRateLimit.concurrency"]);
|
||||
const healthH = num(v["sites.defaultHealthCheckIntervalSec"]);
|
||||
const detailH = num(v["sites.defaultDetailCacheSec"]);
|
||||
return {
|
||||
sites: {
|
||||
defaultRateLimit: {
|
||||
@@ -70,6 +93,9 @@ const DEFAULTS_CARD: CardSpec = {
|
||||
},
|
||||
defaultSnapshotIntervalSec: min === undefined ? undefined : Math.round(min * 60),
|
||||
userDataMaxSeedingPages: num(v["sites.userDataMaxSeedingPages"]),
|
||||
defaultSnapshotMaxPages: num(v["sites.defaultSnapshotMaxPages"]),
|
||||
defaultHealthCheckIntervalSec: healthH === undefined ? undefined : Math.round(healthH * 3600),
|
||||
defaultDetailCacheSec: detailH === undefined ? undefined : Math.round(detailH * 3600),
|
||||
userAgent: str(v["sites.userAgent"]) || undefined,
|
||||
proxyUrl: str(v["sites.proxyUrl"]) || null,
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SiteListItem } from "@umt/contracts";
|
||||
import { useSiteUserData } from "~/api/sites.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";
|
||||
@@ -26,6 +26,7 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
|
||||
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);
|
||||
|
||||
@@ -104,6 +105,17 @@ export function SiteOverview({ site }: { site: SiteListItem }) {
|
||||
: "未开启",
|
||||
},
|
||||
{ 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 ? "使用" : "不使用" },
|
||||
]}
|
||||
/>
|
||||
|
||||
@@ -41,6 +41,97 @@ export function promotionLabel(r: Pick<SourceRelease, "promotion" | "promotionEx
|
||||
export const isFree = (r: Pick<SourceRelease, "promotion">) =>
|
||||
r.promotion.kind === "free" || r.promotion.kind === "2xfree";
|
||||
|
||||
/** 完成数(站点 raw._site.snatched);没有时 null */
|
||||
export function snatchedOf(r: Pick<SourceRelease, "raw">): number | null {
|
||||
const v = siteExtra(r).snatched;
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
||||
}
|
||||
|
||||
/** 促销剩余时间:"剩 3 小时";没有截止时间或已过期返回 null */
|
||||
export function remainingLabel(iso: string | null | undefined, now = Date.now()): string | null {
|
||||
if (!iso) return null;
|
||||
const left = new Date(iso).getTime() - now;
|
||||
if (!Number.isFinite(left) || left <= 0) return null;
|
||||
if (left < 3_600_000) return `剩 ${Math.max(1, Math.round(left / 60_000))} 分钟`;
|
||||
if (left < 2 * 86_400_000) return `剩 ${Math.round(left / 3_600_000)} 小时`;
|
||||
return `剩 ${Math.round(left / 86_400_000)} 天`;
|
||||
}
|
||||
|
||||
/** 绝对时间(本地时区,精确到分钟),用在悬停提示 */
|
||||
export function absTime(iso: string | null | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
const p = (n: number) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
type ParsedLike = {
|
||||
resolution?: string;
|
||||
source?: string;
|
||||
videoCodec?: string;
|
||||
hdr?: string[];
|
||||
releaseGroup?: { name?: string };
|
||||
subtitles?: string[];
|
||||
season?: number;
|
||||
seasonEnd?: number;
|
||||
seasonInferred?: boolean;
|
||||
episodes?: { start: number; end: number };
|
||||
totalEpisodes?: number;
|
||||
isSeasonPack?: boolean;
|
||||
};
|
||||
|
||||
export type SpecTagKind = "episode" | "resolution" | "source" | "codec" | "hdr" | "group" | "lang" | "site";
|
||||
export interface SpecTag {
|
||||
key: string;
|
||||
label: string;
|
||||
kind: SpecTagKind;
|
||||
}
|
||||
|
||||
const pad2 = (n: number) => String(n).padStart(2, "0");
|
||||
|
||||
/**
|
||||
* 资源规格标签:季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字,再加站点自带标签(tagsRaw)。
|
||||
* 都来自标题解析结果与标题、副标题原文,不猜;同名去重。
|
||||
*/
|
||||
export function releaseSpecTags(r: Pick<SourceRelease, "parsed" | "title" | "subtitle" | "tagsRaw">): SpecTag[] {
|
||||
const p = (r.parsed ?? {}) as ParsedLike;
|
||||
const out: SpecTag[] = [];
|
||||
const seen = new Set<string>();
|
||||
const add = (kind: SpecTagKind, label: string | undefined | null) => {
|
||||
const l = label?.trim();
|
||||
if (!l || seen.has(l.toLowerCase())) return;
|
||||
seen.add(l.toLowerCase());
|
||||
out.push({ key: `${kind}:${l}`, label: l, kind });
|
||||
};
|
||||
// 季号是推定的(标题里没写)就不显示季,只显示集
|
||||
const season =
|
||||
p.season !== undefined && !p.seasonInferred
|
||||
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
|
||||
: "";
|
||||
const episode = p.episodes
|
||||
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
|
||||
: "";
|
||||
const total = !episode && p.totalEpisodes ? `全 ${p.totalEpisodes} 集` : "";
|
||||
add("episode", [`${season}${episode}`, total].filter(Boolean).join(" "));
|
||||
add("resolution", p.resolution === "2160p" ? "4K" : p.resolution);
|
||||
add("source", p.source);
|
||||
add("codec", p.videoCodec);
|
||||
for (const h of p.hdr ?? []) add("hdr", h);
|
||||
add("group", p.releaseGroup?.name);
|
||||
const text = `${r.title} ${r.subtitle ?? ""}`;
|
||||
if (/国语|國語|国配|普通话|中配/.test(text)) add("lang", "国语");
|
||||
if (/粤语|粵語/.test(text)) add("lang", "粤语");
|
||||
if (
|
||||
/中字|中文字幕|简中|繁中|简繁|簡繁|双语字幕|雙語字幕|内嵌中|内封中/.test(text) ||
|
||||
(p.subtitles ?? []).some((l) => l.startsWith("zh"))
|
||||
) {
|
||||
add("lang", "中字");
|
||||
}
|
||||
for (const t of r.tagsRaw ?? []) add("site", t);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 相对时间(中文);未来时间显示"x 后" */
|
||||
export function relTime(iso: string | null | undefined, now = Date.now()): string {
|
||||
if (!iso) return "—";
|
||||
|
||||
@@ -1,21 +1,34 @@
|
||||
import type { InventoryEntry, WorkbenchRelease } from "@umt/contracts";
|
||||
import { ArrowDown, ArrowUp, Clock } from "lucide-react";
|
||||
import { ArrowDown, ArrowUp, Check } from "lucide-react";
|
||||
import { inventoryFlags } from "~/api/inventory.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { categoryLabel, hrOf, ORIGIN_LABEL, promotionLabel, relTime } from "./release-fields.ts";
|
||||
import {
|
||||
absTime,
|
||||
categoryLabel,
|
||||
hrOf,
|
||||
ORIGIN_LABEL,
|
||||
promotionLabel,
|
||||
releaseSpecTags,
|
||||
relTime,
|
||||
remainingLabel,
|
||||
snatchedOf,
|
||||
} from "./release-fields.ts";
|
||||
|
||||
/** 促销 / H&R 徽章(列表与详情共用) */
|
||||
/** 促销 / H&R 徽章(列表与详情共用):促销带剩余时间,过期的写明 */
|
||||
export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
const promo = promotionLabel(r);
|
||||
const hr = hrOf(r);
|
||||
const left = promo && !promo.expired ? remainingLabel(r.promotionExpiresAt) : null;
|
||||
return (
|
||||
<>
|
||||
{promo ? (
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "neutral"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : r.promotionExpiresAt ? ` · ${relTime(r.promotionExpiresAt)}截止` : ""}
|
||||
</StatusBadge>
|
||||
<span title={r.promotionExpiresAt ? `促销截止 ${absTime(r.promotionExpiresAt)}` : undefined}>
|
||||
<StatusBadge tone={promo.expired ? "neutral" : promo.free ? "success" : "warning"}>
|
||||
{promo.label}
|
||||
{promo.expired ? "(已过期)" : left ? ` · ${left}` : ""}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
) : null}
|
||||
{hr === "yes" ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
|
||||
{hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
|
||||
@@ -23,6 +36,40 @@ export function ReleaseBadges({ r }: { r: WorkbenchRelease }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** 规格标签(季集 / 分辨率 / 来源 / 编码 / HDR / 发布组 / 国语·中字 / 站点标签):中性样式,不占状态色 */
|
||||
export function SpecTags({ r }: { r: Pick<WorkbenchRelease, "parsed" | "title" | "subtitle" | "tagsRaw"> }) {
|
||||
const tags = releaseSpecTags(r);
|
||||
if (!tags.length) return null;
|
||||
return (
|
||||
<>
|
||||
{tags.map((t) => (
|
||||
<span
|
||||
key={t.key}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
|
||||
t.kind === "resolution" || t.kind === "hdr"
|
||||
? "border-border text-foreground"
|
||||
: "border-border/70 text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{t.label}
|
||||
</span>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 没有关联作品时的说明(明确写出,不猜) */
|
||||
export function unresolvedLabel(release: Pick<WorkbenchRelease, "resolutionStatus">): string {
|
||||
return release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
}
|
||||
|
||||
/** 库存叠加:只来自本地身份/Slot/任务;未识别的资源明确写"未识别",不猜 */
|
||||
export function InventoryOverlay({
|
||||
release,
|
||||
@@ -33,17 +80,7 @@ export function InventoryOverlay({
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity) {
|
||||
const label =
|
||||
release.resolutionStatus === "ambiguous"
|
||||
? "识别有歧义"
|
||||
: release.resolutionStatus === "unidentifiable"
|
||||
? "无法识别"
|
||||
: release.resolutionStatus === "rejected"
|
||||
? "已排除"
|
||||
: "未识别到本地作品";
|
||||
return <span className="text-meta text-muted-foreground">{label}</span>;
|
||||
}
|
||||
if (!release.entity) return <span className="text-meta text-muted-foreground">{unresolvedLabel(release)}</span>;
|
||||
const flags = inventoryFlags(entry, release.resolutionStatus, release.id);
|
||||
return (
|
||||
<span className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
@@ -68,6 +105,11 @@ export function InventoryOverlay({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源列表行,两层:
|
||||
* 主行 = 标题 + 规格标签 + 促销 / H&R;副行 = 副标题(单行省略)+ 分类 / 大小 / 做种·下载·完成 / 上传时间 + 库存叠加。
|
||||
* 宽屏副行的数字列定宽右对齐,上下行对得齐;窄屏整行折成卡片,数字换到副标题下面。
|
||||
*/
|
||||
export function ReleaseRow({
|
||||
r,
|
||||
entry,
|
||||
@@ -86,6 +128,11 @@ export function ReleaseRow({
|
||||
}) {
|
||||
const stale = staleBefore !== null && new Date(r.dataUpdatedAt).getTime() < staleBefore;
|
||||
const cat = categoryLabel(r);
|
||||
const snatched = snatchedOf(r);
|
||||
const timeTitle = [
|
||||
r.sourcePublishedAt ? `上传 ${absTime(r.sourcePublishedAt)}` : "上传时间未知",
|
||||
`${ORIGIN_LABEL[r.origin]}数据 ${relTime(r.dataUpdatedAt)}确认`,
|
||||
].join("\n");
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
@@ -93,37 +140,68 @@ export function ReleaseRow({
|
||||
onClick={onOpen}
|
||||
aria-current={selected ? "true" : undefined}
|
||||
className={cn(
|
||||
"block w-full min-w-0 cursor-pointer px-4 py-3 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
"group block w-full min-w-0 cursor-pointer px-4 py-2.5 text-left transition-colors duration-micro outline-none hover:bg-foreground/[0.04] focus-visible:bg-foreground/[0.06]",
|
||||
selected && "bg-foreground/[0.05]",
|
||||
)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
|
||||
<span className="flex flex-wrap gap-1">
|
||||
<ReleaseBadges r={r} />
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<span
|
||||
className="min-w-0 text-sm font-medium text-foreground [overflow-wrap:anywhere] group-hover:underline group-hover:underline-offset-2"
|
||||
title={r.title}
|
||||
>
|
||||
{r.title}
|
||||
</span>
|
||||
<SpecTags r={r} />
|
||||
<ReleaseBadges r={r} />
|
||||
</div>
|
||||
<div className="mt-1 flex min-w-0 flex-col gap-1 text-meta text-muted-foreground sm:flex-row sm:items-center sm:gap-4">
|
||||
<span className="min-w-0 truncate sm:flex-1" title={r.subtitle ?? undefined}>
|
||||
{r.subtitle ?? ""}
|
||||
</span>
|
||||
<span className="flex flex-wrap items-center gap-x-3 gap-y-1 tabular-nums sm:shrink-0 sm:flex-nowrap sm:gap-x-4">
|
||||
<span className="sm:w-14 sm:truncate" title={r.categoryRaw ? `站点分类 ${r.categoryRaw}` : undefined}>
|
||||
{cat ?? "分类未知"}
|
||||
</span>
|
||||
<span className="sm:w-16 sm:text-right">
|
||||
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-0.5 sm:w-32" title="做种 / 下载 / 完成">
|
||||
<ArrowUp className="h-3 w-3" aria-label="做种" />
|
||||
<span className={cn(r.seeders === 0 && "text-warning")}>{r.seeders ?? "?"}</span>
|
||||
<ArrowDown className="ml-1.5 h-3 w-3" aria-label="下载" />
|
||||
{r.leechers ?? "?"}
|
||||
<Check className="ml-1.5 h-3 w-3" aria-label="完成" />
|
||||
{snatched ?? "?"}
|
||||
</span>
|
||||
<span className="sm:w-20 sm:text-right" title={timeTitle}>
|
||||
{relTime(r.sourcePublishedAt)}
|
||||
</span>
|
||||
{!r.entity && r.resolutionStatus !== "ambiguous" ? (
|
||||
<span className="sm:w-24 sm:truncate">{unresolvedLabel(r)}</span>
|
||||
) : null}
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</span>
|
||||
</div>
|
||||
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums">
|
||||
{cat ? <span>{cat}</span> : null}
|
||||
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
|
||||
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
|
||||
<ArrowUp className="h-3 w-3" />
|
||||
{r.seeders ?? "?"}
|
||||
<ArrowDown className="ml-1 h-3 w-3" />
|
||||
{r.leechers ?? "?"}
|
||||
</span>
|
||||
<span title="站点上传时间">上传 {relTime(r.sourcePublishedAt)}</span>
|
||||
<span className="inline-flex items-center gap-1" title={`数据最近确认:${r.dataUpdatedAt}`}>
|
||||
<Clock className="h-3 w-3" />
|
||||
{ORIGIN_LABEL[r.origin]} · {relTime(r.dataUpdatedAt)}
|
||||
</span>
|
||||
{stale ? <StatusBadge tone="warning">过期</StatusBadge> : null}
|
||||
</div>
|
||||
<div className="mt-1.5">
|
||||
<InventoryOverlay release={r} entry={entry} loading={inventoryLoading} />
|
||||
</div>
|
||||
<InventoryLine release={r} entry={entry} loading={inventoryLoading} />
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/** 副行末尾的库存叠加:只在识别到作品(或识别有歧义)时占一行,未识别的不重复刷"无法识别" */
|
||||
function InventoryLine({
|
||||
release,
|
||||
entry,
|
||||
loading,
|
||||
}: {
|
||||
release: WorkbenchRelease;
|
||||
entry: InventoryEntry | undefined;
|
||||
loading: boolean;
|
||||
}) {
|
||||
if (!release.entity && release.resolutionStatus !== "ambiguous") return null;
|
||||
return (
|
||||
<div className="mt-1.5 min-w-0">
|
||||
<InventoryOverlay release={release} entry={entry} loading={loading} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ 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 { snapshotStale, useSites, useSnapshotSite } from "~/api/sites.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";
|
||||
@@ -157,6 +157,7 @@ export default function SiteWorkbenchPage() {
|
||||
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("");
|
||||
@@ -292,10 +293,14 @@ export default function SiteWorkbenchPage() {
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!s?.enabled || snap.isPending}
|
||||
title="手动快照(受站点限流)"
|
||||
title={
|
||||
reqStats.data
|
||||
? `抓取站点列表前 ${reqStats.data.access.snapshotMaxPages} 页(受站点限流)`
|
||||
: "抓取站点列表(受站点限流)"
|
||||
}
|
||||
onClick={() =>
|
||||
snap.mutate(
|
||||
{ id: siteId, maxPages: 2 },
|
||||
{ id: siteId },
|
||||
{
|
||||
onSuccess: () => toast.success("快照已排队"),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
@@ -389,6 +394,11 @@ export default function SiteWorkbenchPage() {
|
||||
已加载 {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">
|
||||
|
||||
Reference in new issue
Block a user