feat(web): merge search filters into one row and tidy episode versions (POLISH-1)

- H&R becomes a quick-filter facet (yes/no/unknown, with counts) on search
  results and the site resources tab; drop the old free/H&R selects, keep
  size range and sort on the same row (mobile: drawer), single clear button
- legacy free/hr URL params migrate into quick filters; stored selections
  keep the same localStorage key
- search timing and query terms move into TechInfo
- library episode versions: click the whole row to expand, versions listed
  as sibling rows, details only under the selected version, raw values in
  TechInfo
- slot comparison gaps get reason codes (matchGapText in contracts
  reasons.ts): "可换成 2160p", "缺 HDR 版本"

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:06:32 +08:00
1 parent 12bc4664b6
commit 71b129d85a
19 files changed
+668 -290

No files matched your search

@@ -17,8 +17,8 @@ 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 { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { TechInfo } from "~/components/console/tech-info.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
@@ -26,13 +26,19 @@ import { relTime } 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, type QuickSelection, selectedCount, useQuickFilters } from "./quick-filter-state.ts";
import {
formatQuickFilter,
mergeLegacyFilters,
type QuickSelection,
selectedCount,
useQuickFilters,
} from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/制作组/季集,多选带计数)+ 大小/免费/H&R、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
@@ -76,7 +82,6 @@ export function toApiFilters(
siteIds = "",
quick: QuickSelection = {},
): ReleaseSearchFilters {
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
...target,
@@ -87,8 +92,7 @@ export function toApiFilters(
qf: formatQuickFilter(quick),
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
free: bool(f.free),
hr: bool(f.hr),
// free / hr 是旧筛选行的 URL 参数,进页面时并进快速筛选(mergeLegacyFilters),不再单独发
sort,
order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined,
};
@@ -99,7 +103,6 @@ export function ReleaseResults({
siteIds,
filters,
setFilter,
resetFilters,
onChangeSites,
}: {
target: ReleaseTarget;
@@ -108,7 +111,6 @@ export function ReleaseResults({
onChangeSites?: () => void;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const [quick, setQuick] = useQuickFilters("search");
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
@@ -120,10 +122,20 @@ export function ReleaseResults({
const [dl, setDl] = useState<SearchRelease | null>(null);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const resetAll = () => {
// 旧链接里的免费 / 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({});
resetFilters();
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">
@@ -142,8 +154,7 @@ export function ReleaseResults({
{first ? (
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
{relTime(first.fetchedAt)} · {first.tookMs} ms
{first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}
{relTime(first.fetchedAt)}
</p>
) : null}
</div>
@@ -167,8 +178,34 @@ export function ReleaseResults({
{first ? <CoverageNotice page={first} /> : null}
{first ? <QuickFilterBar facets={first.facets} value={quick} onChange={setQuick} /> : null}
{first ? <Filters filters={filters} setFilter={setFilter} onReset={filtered ? resetAll : undefined} /> : null}
{first ? (
<QuickFilterBar
facets={first.facets}
value={quick}
onChange={setQuick}
onClearAll={clearAll}
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 ? (
@@ -202,6 +239,18 @@ export function ReleaseResults({
) : 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}
</section>
);
@@ -258,100 +307,99 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
);
}
function Filters({
/** 大小区间的标签文字:「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,
onReset,
layout,
}: {
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
onReset?: () => 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());
return (
<div className="mb-3 space-y-2.5">
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-meta text-muted-foreground">大小</span>
<Input
inputMode="decimal"
value={minGb}
onChange={(e) => setMinGb(e.target.value)}
onBlur={() => setFilter("minGb", minGb.trim())}
onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())}
placeholder="最小"
aria-label="最小大小(GB)"
className="h-8 w-20"
/>
<span className="text-meta text-muted-foreground">–</span>
<Input
inputMode="decimal"
value={maxGb}
onChange={(e) => setMaxGb(e.target.value)}
onBlur={() => setFilter("maxGb", maxGb.trim())}
onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())}
placeholder="最大"
aria-label="最大大小(GB)"
className="h-8 w-20"
/>
<span className="text-meta text-muted-foreground">GB</span>
</div>
<FilterSelect
label="免费"
value={filters.free}
onChange={(v) => setFilter("free", v)}
options={[
{ value: "true", label: "只看免费" },
{ value: "false", label: "排除免费" },
]}
/>
<FilterSelect
label="H&R"
value={filters.hr}
onChange={(v) => setFilter("hr", v)}
options={[
{ value: "true", label: "只看 H&R" },
{ value: "false", label: "排除 H&R" },
]}
/>
<div className="flex items-center gap-1">
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
<SelectTrigger size="sm" aria-label="排序" className="min-w-32">
<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>
{onReset ? (
<Button variant="ghost" size="sm" onClick={onReset}>
清除筛选
</Button>
) : null}
</div>
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 ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) {