Files
UHD_Media_Tool/apps/web/app/components/search/release-results.tsx
T
rootandClaude Opus 5.5 39ec8185e9 feat(web): 资源搜索结果改版(规格标签、促销前置、数字对齐、按作品分组、规则判定)
参考 MP 资源卡片:促销放在标题前(免费用 success,其余折扣中性加粗,带剩余时间);
规格拆成中性小标签(季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 语言 → 制作组);
站点 / 大小 / 做种·下载 / 发布时间宽屏定宽对齐、窄屏折行,做种加粗、为 0 时弱化;
结果涉及多部作品时按作品分组(组头 = 作品名 + 年份 + 库存状态),只有一部时不重复作品名;
每行显示服务端规则判定(命中第 N 档 / 不满足 / 与库里版本比较)。保留工作台、委托智能体、下载。

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

630 lines
23 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 { 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)。
/** 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 ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
className="py-12"
/>
) : (
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
)}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : 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>
);
}
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
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">
<Link to={`/console/sites/${r.siteId}`} title="在站点工作台查看">
<ExternalLink />
工作台
</Link>
</Button>
) : null}
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
<Bot />
委托智能体
</Button>
<Button size="sm" onClick={onDownload}>
<Download />
下载
</Button>
</span>
</div>
</li>
);
}