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:
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 }) {
|
||||
|
||||
Reference in new issue
Block a user