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

@@ -1,6 +1,6 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { Check, ChevronDown, X } from "lucide-react";
import { useState } from "react";
import { Check, ChevronDown, SlidersHorizontal, X } from "lucide-react";
import { type ReactNode, useState } from "react";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
@@ -18,8 +18,12 @@ import {
} from "./quick-filter-state.ts";
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,可「清除全部」。
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
/** 分面之外的已选条件(如大小区间),和分面标签排在一起 */
export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void };
export function QuickFilterBar({
facets,
@@ -27,6 +31,10 @@ export function QuickFilterBar({
onChange,
hide = [],
className,
aside,
extraTags = [],
onClearAll,
hideClear = false,
}: {
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
@@ -34,9 +42,17 @@ export function QuickFilterBar({
/** 不显示的维度(站点资源 Tab 不显示站点) */
hide?: QuickDim[];
className?: string;
/** 同一行右侧的控件(桌面);手机上收进一个抽屉,label 是入口名,active 时入口高亮 */
aside?: { content: (layout: "inline" | "sheet") => ReactNode; label: string; active: boolean };
extraTags?: ExtraTag[];
/** 「清除」同时清掉页面自己的条件;缺省只清分面 */
onClearAll?: () => void;
/** 页面已有自己的「清除筛选」时不再显示 */
hideClear?: boolean;
}) {
const mobile = useIsMobile();
const [sheet, setSheet] = useState<QuickGroup | null>(null);
const [asideOpen, setAsideOpen] = useState(false);
const groups = QUICK_GROUPS.filter((g) => {
const dims = g.dims.filter((d) => !hide.includes(d.dim));
if (!dims.length) return false;
@@ -52,74 +68,110 @@ export function QuickFilterBar({
);
});
const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim));
if (!groups.length && !tags.length) return null;
if (!groups.length && !tags.length && !extraTags.length && !aside) return null;
const clearAll = onClearAll ?? (() => onChange({}));
return (
<div className={cn("mb-3 min-w-0 space-y-2", className)}>
<div
role="toolbar"
aria-label="快速筛选"
className="-mx-1 flex min-w-0 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
>
{groups.map((g) => {
const dims = g.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
const chip = (
<div className="flex min-w-0 items-center gap-2">
<div
role="toolbar"
aria-label="快速筛选"
className="-mx-1 flex min-w-0 flex-1 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
>
{/* 手机:大小与排序放在最前,免得滚到最后才看到 */}
{aside && mobile ? (
<button
key={g.key}
type="button"
aria-haspopup="dialog"
onClick={mobile ? () => setSheet(g) : undefined}
onClick={() => setAsideOpen(true)}
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
n
aside.active
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{g.label}
{n ? (
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
{n}
</span>
) : null}
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
<SlidersHorizontal className="h-3.5 w-3.5" />
{aside.label}
</button>
);
return mobile ? (
chip
) : (
<Popover key={g.key}>
<PopoverTrigger asChild>{chip}</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<GroupPanel group={g} facets={facets} value={value} onChange={onChange} />
</PopoverContent>
</Popover>
);
})}
) : null}
{groups.map((g) => {
const dims = g.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
const chip = (
<button
key={g.key}
type="button"
aria-haspopup="dialog"
onClick={mobile ? () => setSheet(g) : undefined}
className={cn(
"inline-flex h-8 shrink-0 cursor-pointer items-center gap-1 rounded-full border px-3 text-caption font-medium whitespace-nowrap transition-[background-color,color,transform] duration-micro active:scale-[0.96]",
n
? "pill-selected text-foreground"
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
)}
>
{g.label}
{n ? (
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
{n}
</span>
) : null}
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</button>
);
return mobile ? (
chip
) : (
<Popover key={g.key}>
<PopoverTrigger asChild>{chip}</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<GroupPanel group={g} facets={facets} value={value} onChange={onChange} />
</PopoverContent>
</Popover>
);
})}
</div>
{aside && !mobile ? <div className="flex shrink-0 items-center gap-1.5">{aside.content("inline")}</div> : null}
</div>
{tags.length ? (
{tags.length || extraTags.length ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{tags.map((t) => (
<button
<RemovableTag
key={`${t.dim}:${t.value}`}
type="button"
onClick={() => onChange(toggleValue(value, t.dim, t.value))}
aria-label={`移除筛选 ${t.dimLabel} ${t.label}`}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
>
<span className="text-muted-foreground">{t.dimLabel}</span>
<span className="max-w-40 truncate">{t.label}</span>
<X className="h-3 w-3 text-muted-foreground" />
</button>
dimLabel={t.dimLabel}
label={t.label}
onRemove={() => onChange(toggleValue(value, t.dim, t.value))}
/>
))}
<Button variant="ghost" size="sm" className="h-7" onClick={() => onChange({})}>
清除全部
</Button>
{extraTags.map((t) => (
<RemovableTag key={t.key} dimLabel={t.dimLabel} label={t.label} onRemove={t.onRemove} />
))}
{hideClear ? null : (
<Button variant="ghost" size="sm" className="h-7" onClick={clearAll}>
清除
</Button>
)}
</div>
) : null}
{aside && mobile ? (
<Sheet open={asideOpen} onOpenChange={setAsideOpen}>
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
<SheetHeader className="pb-2">
<SheetTitle>{aside.label}</SheetTitle>
<SheetDescription className="sr-only">{aside.label}</SheetDescription>
</SheetHeader>
<div className="min-h-0 space-y-4 overflow-y-auto px-4 pb-4">{aside.content("sheet")}</div>
<SheetFooter className="border-t border-border/60">
<Button onClick={() => setAsideOpen(false)}>完成</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) : null}
{mobile ? (
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
@@ -166,6 +218,21 @@ export function QuickFilterBar({
);
}
function RemovableTag({ dimLabel, label, onRemove }: { dimLabel: string; label: string; onRemove: () => void }) {
return (
<button
type="button"
onClick={onRemove}
aria-label={`移除筛选 ${dimLabel} ${label}`}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
>
<span className="text-muted-foreground">{dimLabel}</span>
<span className="max-w-40 truncate">{label}</span>
<X className="h-3 w-3 text-muted-foreground" />
</button>
);
}
/** 一个入口的多选列表:每项带计数;0 计数且未选时置灰不可选 */
function GroupPanel({
group,
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import {
clearDims,
formatQuickFilter,
mergeLegacyFilters,
parseStored,
persistable,
quickFilterStorageKey,
@@ -19,6 +20,7 @@ const empty = {
site: [],
promotion: [],
hdr: [],
hr: [],
releaseGroup: [],
season: [],
episode: [],
@@ -60,4 +62,20 @@ describe("快速筛选状态", () => {
});
expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search"));
});
it("H&R 分面也记忆;旧版存的内容(没有 hr)照常读出,键不变", () => {
expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] });
expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search");
const old = JSON.stringify({ resolution: ["2160p"], promotion: ["free"] });
expect(parseStored(old)).toEqual({ resolution: ["2160p"], promotion: ["free"] });
});
it("旧 URL 参数 free / hr 并进快速筛选", () => {
expect(mergeLegacyFilters({ promotion: ["2x"] }, { free: "true", hr: "true" })).toEqual({
promotion: ["2x", "free"],
hr: ["yes"],
});
expect(mergeLegacyFilters({}, { hr: "false" })).toEqual({ hr: ["no", "unknown"] });
expect(mergeLegacyFilters({}, { free: "false" })).toEqual({});
});
});
@@ -17,6 +17,7 @@ export const QUICK_GROUPS: QuickGroup[] = [
{ key: "site", label: "站点", dims: [{ dim: "site", label: "站点" }] },
{ key: "promotion", label: "促销", dims: [{ dim: "promotion", label: "促销" }] },
{ key: "hdr", label: "HDR", dims: [{ dim: "hdr", label: "HDR" }] },
{ key: "hr", label: "H&R", dims: [{ dim: "hr", label: "H&R" }] },
{ key: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] },
{
key: "seasonEpisode",
@@ -31,7 +32,7 @@ export const QUICK_GROUPS: QuickGroup[] = [
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr"];
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr", "hr"];
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
export function formatQuickFilter(sel: QuickSelection): string | undefined {
@@ -98,6 +99,23 @@ export function parseStored(raw: string | null): QuickSelection {
}
}
/**
* 旧版筛选行(免费 / H&R 下拉,URL 参数 free、hr)并进快速筛选(POLISH-1):
* free=true → 促销「免费」;hr=true → H&R「有」;hr=false → H&R「无 / 未知」(旧口径只排除明确 H&R)。
* free=false 没有对应的分面组合,丢弃。
*/
export function mergeLegacyFilters(sel: QuickSelection, legacy: { free?: string; hr?: string }): QuickSelection {
const add = (s: QuickSelection, dim: QuickDim, vals: string[]): QuickSelection => ({
...s,
[dim]: [...new Set([...(s[dim] ?? []), ...vals])],
});
let next = sel;
if (legacy.free === "true") next = add(next, "promotion", ["free"]);
if (legacy.hr === "true") next = add(next, "hr", ["yes"]);
if (legacy.hr === "false") next = add(next, "hr", ["no", "unknown"]);
return next;
}
/** localStorage 键:按页面区分(搜索结果页 / 站点资源 Tab) */
export const quickFilterStorageKey = (page: "search" | "site") => `umt.quickFilters.${page}`;
@@ -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 }) {