Files
UHD_Media_Tool/apps/web/app/components/search/release-results.tsx
T
rootandClaude Opus 5.5 21c55dfb3a feat(web): 记录抽屉与两层导航标准
控制台导航最多两层(侧栏一项 = 一页,页内最多一层页签 / 分段);从列表打开的
记录改成列表页上的右侧抽屉,打开的记录写在 ?open=,刷新、分享、前进后退都能还原,
后退就是关抽屉。标准写在 docs/design/console-ia.md §9,ui-controls.md 引用。

- DetailSheet 加 xl 宽度、标题下操作行;抽屉里再开的 DetailSheet 在同一抽屉里换成子视图(返回 / Esc)
- record-sheet.tsx:useRecordSheet / RecordLink / useOpenRecord / recordPath / taskPath
- 整理记录、订阅、站点(含页签)、下载、日志任务改成抽屉;旧业务任务 / 下载任务 id 在任务抽屉里换算
- 回收站并进文件管理右侧视图(?view=trash);设置左栏只列分区
- 规则组、策略版本、媒体库未识别 / 外部身份、设置实例编辑走 ?open=
- 旧地址 /console/{organize,subscriptions,sites,downloads}/:id、/tasks/:id、/files/trash 跳到列表页抽屉
- 站内链接、铃铛、命令面板、任务时间线改拼新地址;冒烟按 ?open= 检查抽屉

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

690 lines
25 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 { ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import {
ArrowDown,
ArrowDownWideNarrow,
ArrowUp,
ArrowUpNarrowWide,
Bot,
ChevronDown,
Download,
ExternalLink,
PackageSearch,
RefreshCw,
TriangleAlert,
UsersRound,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
import { toast } from "sonner";
import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts";
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { RecordLink } from "~/components/console/record-sheet.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { absTime, relTime, remainingLabel } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { QuickFilterBar } from "./quick-filter-bar.tsx";
import {
formatQuickFilter,
mergeLegacyFilters,
type QuickSelection,
selectedCount,
useQuickFilters,
} from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import {
gbToBytes,
groupByWork,
hrBadge,
INVENTORY_STATE,
promoBadge,
releaseSpecTags,
SITE_STATUS,
SORT_OPTIONS,
verdictView,
vsLibraryLabel,
type WorkGroup,
} from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
// 按作品搜时,服务端判定不属于这部作品的资源(对照 MP match_torrent)不参与筛选与分面,
// 在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
sites: string;
resolution: string;
source: string;
minGb: string;
maxGb: string;
free: string;
hr: string;
sort: string;
order: string;
};
export const RELEASE_FILTER_KEYS = [
"sites",
"resolution",
"source",
"minGb",
"maxGb",
"free",
"hr",
"sort",
"order",
] as const satisfies readonly (keyof ReleaseFilterState)[];
/** 搜什么:关键词,或按作品(服务端生成查询词) */
export type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string };
/** URL 参数 work → 搜索目标:local:<id> / tmdb:<type>:<id> / q:<关键词> */
export function parseWorkRef(ref: string): ReleaseTarget | null {
if (ref.startsWith("local:")) return { entityId: ref.slice(6) };
const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref);
if (m) return { tmdb: `${m[1]}:${m[2]}` };
if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() };
return null;
}
export function toApiFilters(
target: ReleaseTarget,
f: ReleaseFilterState,
siteIds = "",
quick: QuickSelection = {},
): ReleaseSearchFilters {
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
...target,
sites: siteIds || undefined,
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
qf: formatQuickFilter(quick),
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
// free / hr 是旧筛选行的 URL 参数,进页面时并进快速筛选(mergeLegacyFilters),不再单独发
sort,
order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined,
};
}
export function ReleaseResults({
target,
siteIds,
filters,
setFilter,
onChangeSites,
}: {
target: ReleaseTarget;
/** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */
siteIds: string;
onChangeSites?: () => void;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
}) {
const [quick, setQuick] = useQuickFilters("search");
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const paged = useListPagination(rows, {
hasMore: res.hasNextPage,
onLoadMore: () => res.fetchNextPage(),
loadingMore: res.isFetchingNextPage,
error: res.error,
resetKey: JSON.stringify(api),
});
const [dl, setDl] = useState<SearchRelease | null>(null);
const [selected, setSelected] = useState<SearchRelease | null>(null);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
// 旧链接里的免费 / H&R 参数并进快速筛选
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在旧参数出现时迁移一次
useEffect(() => {
if (!filters.free && !filters.hr) return;
setQuick(mergeLegacyFilters(quick, { free: filters.free, hr: filters.hr }));
setFilter("free", "");
setFilter("hr", "");
}, [filters.free, filters.hr]);
/** 唯一的「清除」:分面、大小区间都清掉,排序保留 */
const clearAll = () => {
setQuick({});
for (const k of RELEASE_FILTER_KEYS) if (k !== "sort" && k !== "order") setFilter(k, "");
};
const sizeTag = sizeLabel(filters.minGb, filters.maxGb);
return (
<section aria-labelledby="search-releases" className="min-w-0">
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
<div className="min-w-0">
<h2 id="search-releases" className="text-body font-semibold text-foreground">
资源
{first ? (
<span className="ml-2 text-caption font-normal text-muted-foreground tabular-nums">
{first.total === first.unfilteredTotal
? `${first.total} 条`
: `${first.total} / ${first.unfilteredTotal} 条`}
</span>
) : null}
</h2>
{first ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "实时结果"} · 上次抓取{" "}
{relTime(first.fetchedAt)}
</p>
) : null}
</div>
<div className="flex flex-wrap gap-1">
{onChangeSites ? (
<Button variant="ghost" size="sm" onClick={onChangeSites}>
换站点
</Button>
) : null}
<Button
variant="outline"
size="sm"
disabled={refresh.isPending || res.isFetching}
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
>
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
{refresh.isPending ? "刷新中…" : "刷新"}
</Button>
</div>
</div>
{first ? <CoverageNotice page={first} /> : null}
{first ? (
<QuickFilterBar
facets={first.facets}
value={quick}
onChange={setQuick}
onClearAll={clearAll}
// 大小已有精确区间(大小与排序),不再给分档
hide={["size"]}
extraTags={
sizeTag
? [
{
key: "size",
dimLabel: "大小",
label: sizeTag,
onRemove: () => {
setFilter("minGb", "");
setFilter("maxGb", "");
},
},
]
: []
}
aside={{
label: "大小与排序",
active: !!sizeTag || !!filters.sort || filters.order === "asc",
content: (layout) => <SizeSortControls filters={filters} setFilter={setFilter} layout={layout} />,
}}
/>
) : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
<div className="p-4">
<p className="mb-3 text-meta text-muted-foreground">查询各站点中,最长约 15 秒…</p>
<LoadingState rows={4} />
</div>
) : res.isError && !rows.length ? (
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
) : rows.length === 0 ? (
<EmptyState
plain
icon={PackageSearch}
title={
filtered
? "没有符合筛选条件的资源"
: first?.unmatchedTotal
? "没有找到这部作品的资源"
: "所选站点都没有找到资源"
}
description={
filtered
? "放宽筛选条件再看。"
: first?.unmatchedTotal
? "下面是按名字搜到、但可能不是这部作品的资源。"
: "换站点或换关键词再搜,或到站点页检查站点状态。"
}
className="py-12"
/>
) : (
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
)}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
{first?.unmatchedTotal ? (
<UnmatchedReleases
key={JSON.stringify(api)}
rows={first.unmatched ?? []}
total={first.unmatchedTotal}
onOpen={setSelected}
onDownload={setDl}
/>
) : null}
</div>
{first ? (
<TechInfo
className="mt-3"
items={[
{ label: "耗时", value: `${first.tookMs} ms` },
{ label: "查询词", value: first.queries?.length ? first.queries.map((x) => `「${x}」`).join("") : null },
{ label: "搜索词", value: first.q || null },
...first.sites.map((x) => ({ label: x.siteName, value: `${x.count} 条 · ${x.tookMs} ms` })),
]}
/>
) : null}
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
{selected?.sourceReleaseId ? (
<ReleaseDetailSheet
key={selected.key}
siteId={selected.siteId}
release={null}
releaseId={selected.sourceReleaseId}
title={selected.title}
entry={selected.inventory ?? undefined}
onClose={() => setSelected(null)}
/>
) : null}
</section>
);
}
/** 「另有 N 条可能不是这部作品」:默认收起,展开后与上面的资源行一样可查看、下载 */
export function UnmatchedReleases({
rows,
total,
onOpen,
onDownload,
defaultOpen = false,
}: {
rows: SearchRelease[];
total: number;
onOpen: (r: SearchRelease) => void;
onDownload: (r: SearchRelease) => void;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
if (!total) return null;
return (
<div className="border-t border-border/60">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-1.5 px-4 py-2.5 text-left text-caption text-muted-foreground transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground"
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-micro", open && "rotate-180")} />
另有 {total} 条可能不是这部作品
</button>
{open ? (
<div className="border-t border-border/60">
<ReleaseList rows={rows} onOpen={onOpen} onDownload={onDownload} />
</div>
) : null}
</div>
);
}
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
const [open, setOpen] = useState(false);
const skipped = page.sites.filter((s) => s.status === "skipped");
const ok = page.sites.filter((s) => s.status === "ok").length;
return (
<div className="mb-3 space-y-2">
{page.partial ? (
<div className="flex gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-foreground">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
<div className="min-w-0">
<p>
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
</p>
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
{page.failedSites.map((s) => (
<li key={s.siteId} className="break-all" title={s.error ?? undefined}>
{s.siteName}:{SITE_STATUS[s.status]}
{s.error ? `(${s.error})` : ""}
</li>
))}
</ul>
</div>
</div>
) : null}
{skipped.length ? (
<div className="text-meta text-muted-foreground">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="inline-flex cursor-pointer items-center gap-1 hover:text-foreground"
>
<ChevronDown className={cn("h-3 w-3 transition-transform duration-micro", open && "rotate-180")} />
{skipped.length} 个站点未参与搜索
</button>
{open ? (
<ul className="mt-1 space-y-0.5 pl-4">
{skipped.map((s) => (
<li key={s.siteId} className="break-all">
{s.siteName}:{s.error ?? "不支持搜索"}
</li>
))}
</ul>
) : null}
</div>
) : null}
</div>
);
}
/** 大小区间的标签文字:「5–20 GB」「≥ 5 GB」「≤ 20 GB」 */
export function sizeLabel(min: string, max: string): string | null {
if (min && max) return `${min}–${max} GB`;
if (min) return `≥ ${min} GB`;
if (max) return `≤ ${max} GB`;
return null;
}
/** 大小区间 + 排序:桌面放在快速筛选同一行右侧,手机在抽屉里竖排 */
function SizeSortControls({
filters,
setFilter,
layout,
}: {
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
layout: "inline" | "sheet";
}) {
const [minGb, setMinGb] = useState(filters.minGb);
const [maxGb, setMaxGb] = useState(filters.maxGb);
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
const desc = filters.order !== "asc";
const sheet = layout === "sheet";
const commit = (k: "minGb" | "maxGb", v: string) => setFilter(k, v.trim());
const size = (
<div className="flex items-center gap-1.5">
<span className={cn("text-meta text-muted-foreground", sheet && "w-10 shrink-0")}>大小</span>
<Input
inputMode="decimal"
value={minGb}
onChange={(e) => setMinGb(e.target.value)}
onBlur={() => commit("minGb", minGb)}
onKeyDown={(e) => e.key === "Enter" && commit("minGb", minGb)}
placeholder="最小"
aria-label="最小大小(GB)"
className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")}
/>
<span className="text-meta text-muted-foreground">–</span>
<Input
inputMode="decimal"
value={maxGb}
onChange={(e) => setMaxGb(e.target.value)}
onBlur={() => commit("maxGb", maxGb)}
onKeyDown={(e) => e.key === "Enter" && commit("maxGb", maxGb)}
placeholder="最大"
aria-label="最大大小(GB)"
className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")}
/>
<span className="text-meta text-muted-foreground">GB</span>
</div>
);
const sort = (
<div className="flex items-center gap-1">
{sheet ? <span className="w-10 shrink-0 text-meta text-muted-foreground">排序</span> : null}
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
<SelectTrigger size="sm" aria-label="排序" className={sheet ? "min-w-0 flex-1" : "min-w-28"}>
{sheet ? null : <span className="text-muted-foreground">排序</span>}
<SelectValue />
</SelectTrigger>
<SelectContent position="popper">
{SORT_OPTIONS.map((o) => (
<SelectItem key={o.value} value={o.value}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
title={desc ? "降序" : "升序"}
onClick={() => setFilter("order", desc ? "asc" : "")}
>
{desc ? <ArrowDownWideNarrow className="h-4 w-4" /> : <ArrowUpNarrowWide className="h-4 w-4" />}
</Button>
</div>
);
return sheet ? (
<>
{size}
{sort}
</>
) : (
<>
{size}
<span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
{sort}
</>
);
}
/** 作品组头:作品名 + 年份 + 未确认 + 库存状态 + 条数 */
function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
const inv = g.inventory ? INVENTORY_STATE[g.inventory.state] : null;
return (
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 bg-foreground/[0.03] px-4 py-2">
{g.entity ? (
<>
<Link
to={`/console/library/${g.entity.id}`}
className="min-w-0 truncate text-caption font-semibold text-foreground underline-offset-4 hover:underline"
>
{g.entity.title}
{g.entity.year ? ` (${g.entity.year})` : ""}
</Link>
{g.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
</>
) : (
<span className="text-caption font-semibold text-muted-foreground">未关联本地作品</span>
)}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{count} 条</span>
</div>
);
}
/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
function ReleaseList({
rows,
onOpen,
onDownload,
}: {
rows: SearchRelease[];
onOpen: (r: SearchRelease) => void;
onDownload: (r: SearchRelease) => void;
}) {
const { grouped, groups } = groupByWork(rows);
const single = !grouped && groups[0]?.entity ? groups[0] : null;
const row = (r: SearchRelease) => (
<ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} />
);
if (!grouped)
return (
<>
{single ? <WorkHeader g={single} count={single.items.length} /> : null}
<ul className="divide-y divide-border/60">{rows.map(row)}</ul>
</>
);
return (
<div className="divide-y divide-border/60">
{groups.map((g) => (
<section key={g.key ?? "loose"} aria-label={g.entity ? g.entity.title : "未关联本地作品"}>
<WorkHeader g={g} count={g.items.length} />
<ul className="divide-y divide-border/60 border-t border-border/60">{g.items.map(row)}</ul>
</section>
))}
</div>
);
}
/** 促销放在标题前:免费用 success(属状态),其余折扣中性加粗;带剩余时间 */
function PromoTag({ r }: { r: SearchRelease }) {
const promo = promoBadge(r);
if (!promo) return null;
const left = remainingLabel(r.promotion.expiresAt);
const text = left ? `${promo.label} · ${left}` : promo.label;
const title = r.promotion.expiresAt ? `促销截止 ${absTime(r.promotion.expiresAt)}` : undefined;
return promo.tone === "success" ? (
<span title={title} className="shrink-0">
<StatusBadge tone="success" className="font-semibold">
{text}
</StatusBadge>
</span>
) : (
<span
title={title}
className="inline-flex shrink-0 items-center rounded-full border border-foreground/30 px-2 py-0.5 text-meta font-semibold whitespace-nowrap text-foreground"
>
{text}
</span>
);
}
/** 规格标签:中性小标签,固定顺序;制作组带图标区分 */
function SpecTagList({ r }: { r: SearchRelease }) {
const tags = releaseSpecTags(r.parsed);
if (!tags.length) return null;
return (
<span className="flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t.key}
title={t.title}
className={cn(
"inline-flex items-center gap-0.5 rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
t.dim === "resolution" || t.dim === "hdr" || t.dim === "episode"
? "border-border text-foreground"
: "border-border/70 text-muted-foreground",
)}
>
{t.dim === "group" ? <UsersRound aria-hidden className="h-3 w-3" /> : null}
{t.label}
</span>
))}
</span>
);
}
/** 规则判定 + 与库里版本比较 */
function VerdictLine({ r }: { r: SearchRelease }) {
const v = r.ruleVerdict;
if (!v) return null;
const view = verdictView(v);
const vs = vsLibraryLabel(v);
return (
<span className="flex flex-wrap items-center gap-1">
<span title={view.title || undefined}>
<StatusBadge tone={view.tone}>{view.label}</StatusBadge>
</span>
{vs ? <StatusBadge>{vs}</StatusBadge> : null}
</span>
);
}
/**
* 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
* 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
*/
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
const hr = hrBadge(r);
const seedless = r.seeders === 0 || r.seeders === null;
return (
<li className="flex min-w-0 flex-col gap-2 px-4 py-3 lg:flex-row lg:items-start lg:gap-6">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-start gap-2">
<PromoTag r={r} />
<button
type="button"
onClick={onOpen}
disabled={!r.sourceReleaseId}
className="min-w-0 flex-1 cursor-pointer text-left text-sm font-medium break-all text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:cursor-default disabled:no-underline"
>
{r.title}
</button>
{hr ? (
<span className="shrink-0">
<StatusBadge tone={hr.tone}>{hr.label}</StatusBadge>
</span>
) : null}
</div>
{r.subtitle ? <p className="line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<SpecTagList r={r} />
<VerdictLine r={r} />
</div>
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums lg:flex-nowrap">
<span className="truncate font-medium text-foreground lg:w-24" title={r.category.name ?? undefined}>
{r.siteName}
</span>
<span className="lg:w-16 lg:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5 lg:w-28" title="做种 / 下载">
<ArrowUp aria-label="做种数" className="h-3.5 w-3.5" />
<span className={cn("text-body", seedless ? "text-muted-foreground" : "font-semibold text-foreground")}>
{r.seeders ?? "—"}
</span>
<ArrowDown aria-label="下载数" className="ml-1.5 h-3 w-3" />
{r.leechers ?? "—"}
</span>
<span
className="lg:w-20 lg:text-right"
title={r.uploadedAt ? `发布于 ${absTime(r.uploadedAt)}` : "发布时间未知"}
>
{relTime(r.uploadedAt)}
</span>
</div>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">
<RecordLink page="sites" id={r.siteId} title="打开站点">
<ExternalLink />
站点
</RecordLink>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
交给智能体
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
下载
</Button>
</span>
</div>
</li>
);
}