From b15c06ad20514d848701876649fd276e9f8356e2 Mon Sep 17 00:00:00 2001 From: root Date: Wed, 30 Sep 2026 22:40:55 +0800 Subject: [PATCH] feat(web): more-filters panel and MP ordering for quick filters (SEARCH-4) Co-Authored-By: Claude Opus 5.5 --- .../components/search/quick-filter-bar.tsx | 214 ++++++++++++++++-- .../search/quick-filter-state.test.ts | 53 ++++- .../components/search/quick-filter-state.ts | 59 ++++- .../app/components/search/release-results.tsx | 2 + .../web/app/components/search/search2.test.ts | 2 +- apps/web/app/routes/console/sites.$siteId.tsx | 54 ++--- 6 files changed, 307 insertions(+), 77 deletions(-) diff --git a/apps/web/app/components/search/quick-filter-bar.tsx b/apps/web/app/components/search/quick-filter-bar.tsx index 43b41235..d1dc7de0 100644 --- a/apps/web/app/components/search/quick-filter-bar.tsx +++ b/apps/web/app/components/search/quick-filter-bar.tsx @@ -1,5 +1,6 @@ import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts"; -import { Check, ChevronDown, SlidersHorizontal, X } from "lucide-react"; +import { Check, ChevronDown, ListFilter, SlidersHorizontal, X } from "lucide-react"; +import type * as React from "react"; import { type ReactNode, useState } from "react"; import { useIsMobile } from "~/components/files/use-mobile.ts"; import { Button } from "~/components/ui/button.tsx"; @@ -8,6 +9,7 @@ import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetT import { cn } from "~/lib/utils.ts"; import { clearDims, + MORE_DIMS, QUICK_GROUPS, type QuickDim, type QuickGroup, @@ -21,6 +23,8 @@ import { // 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。 // 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。 // POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。 +// SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R) +// 收进「更多筛选」:桌面一个宽弹层分节列出,手机一个抽屉。弹层与抽屉都是不透明实底(pg-float)。 /** 分面之外的已选条件(如大小区间),和分面标签排在一起 */ export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void }; @@ -52,8 +56,9 @@ export function QuickFilterBar({ }) { const mobile = useIsMobile(); const [sheet, setSheet] = useState(null); + const [moreOpen, setMoreOpen] = useState(false); const [asideOpen, setAsideOpen] = useState(false); - const groups = QUICK_GROUPS.filter((g) => { + const visible = QUICK_GROUPS.filter((g) => { const dims = g.dims.filter((d) => !hide.includes(d.dim)); if (!dims.length) return false; const opts = dims.flatMap((d) => facets?.[d.dim] ?? []); @@ -67,8 +72,12 @@ export function QuickFilterBar({ ) > 0 ); }); + const groups = visible.filter((g) => !g.more); + const moreGroups = visible.filter((g) => g.more); + const moreDims = MORE_DIMS.filter((d) => !hide.includes(d)); + const moreCount = selectedCount(value, moreDims); const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim)); - if (!groups.length && !tags.length && !extraTags.length && !aside) return null; + if (!visible.length && !tags.length && !extraTags.length && !aside) return null; const clearAll = onClearAll ?? (() => onChange({})); return ( @@ -100,26 +109,7 @@ export function QuickFilterBar({ const dims = g.dims.map((d) => d.dim); const n = selectedCount(value, dims); const chip = ( - + setSheet(g) : undefined} /> ); return mobile ? ( chip @@ -132,6 +122,27 @@ export function QuickFilterBar({ ); })} + {moreGroups.length ? ( + mobile ? ( + setMoreOpen(true)} more /> + ) : ( + + + + + + onChange(clearDims(value, moreDims))} + count={moreCount} + /> + + + ) + ) : null} {aside && !mobile ?
{aside.content("inline")}
: null} @@ -172,6 +183,33 @@ export function QuickFilterBar({ ) : null} + {mobile && moreGroups.length ? ( + + + + 更多筛选 + 可多选,数字是选中后的条数 + +
+ +
+ + + + +
+
+ ) : null} + {mobile ? ( !o && setSheet(null)}> @@ -316,3 +354,133 @@ function GroupPanel({ ); } + +/** 筛选条上的入口按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */ +function FilterChip({ + label, + count, + more = false, + className, + ...rest +}: React.ComponentProps<"button"> & { label: string; count: number; more?: boolean }) { + return ( + + ); +} + +/** 「更多筛选」:每个维度一节,选项是带计数的小按钮;0 计数且未选时置灰 */ +function MorePanel({ + groups, + facets, + value, + onChange, + onClear, + count = 0, + bare = false, +}: { + groups: QuickGroup[]; + facets: ReleaseQuickFacets | undefined; + value: QuickSelection; + onChange: (next: QuickSelection) => void; + onClear?: () => void; + count?: number; + bare?: boolean; +}) { + return ( +
+ {bare ? null : ( +
+ 更多筛选 + +
+ )} +
+ {groups.map((g) => + g.dims.map((d) => { + const opts: FacetCount[] = facets?.[d.dim] ?? []; + const chosen = value[d.dim] ?? []; + const extra = chosen.filter((v) => !opts.some((o) => o.value === v)); + const all = [...opts, ...extra.map((v) => ({ value: v, label: v, count: 0 }))]; + if (!all.length) return null; + const n = chosen.length; + return ( +
+ + {d.label} + {n ? ( + + ) : null} + +
+ {all.map((o) => { + const on = chosen.includes(o.value); + const dead = o.count === 0 && !on; + return ( + + ); + })} +
+
+ ); + }), + )} +
+
+ ); +} diff --git a/apps/web/app/components/search/quick-filter-state.test.ts b/apps/web/app/components/search/quick-filter-state.test.ts index 69ad1b16..44b51dcc 100644 --- a/apps/web/app/components/search/quick-filter-state.test.ts +++ b/apps/web/app/components/search/quick-filter-state.test.ts @@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest"; import { clearDims, formatQuickFilter, + MORE_DIMS, mergeLegacyFilters, parseStored, persistable, + QUICK_GROUPS, quickFilterStorageKey, selectedCount, selectedTags, @@ -24,12 +26,23 @@ const empty = { releaseGroup: [], season: [], episode: [], + audioCodec: [], + subtitle: [], + dub: [], + feature: [], + year: [], + category: [], + size: [], + seeders: [], }; describe("快速筛选状态", () => { - it("qf 按维度顺序拼接,值编码;空选择不带参数", () => { + it("qf 按维度顺序(对齐 MP 筛选条)拼接,值编码;空选择不带参数", () => { expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe( - "resolution:2160p;hdr:DV;releaseGroup:A%2CB", + "hdr:DV;resolution:2160p;releaseGroup:A%2CB", + ); + expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe( + "site:s;audioCodec:TrueHD;seeders:0", ); expect(formatQuickFilter({ resolution: [] })).toBeUndefined(); }); @@ -47,8 +60,8 @@ describe("快速筛选状态", () => { it("已选标签取分面里的选项名,取不到用原值", () => { const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] }; expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([ - { dim: "releaseType", value: "encode", dimLabel: "版本", label: "蓝光压制" }, { dim: "season", value: "2", dimLabel: "季", label: "2" }, + { dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" }, ]); }); @@ -63,6 +76,40 @@ describe("快速筛选状态", () => { expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search")); }); + it("SEARCH-4 新增的固定档位维度记忆,年份 / 分类随结果变化不记", () => { + const sel = { audioCodec: ["TrueHD"], subtitle: ["zh"], dub: ["cmn"], year: ["2024"], category: ["电影"] }; + expect(persistable(sel)).toEqual({ audioCodec: ["TrueHD"], subtitle: ["zh"], dub: ["cmn"] }); + expect(parseStored(JSON.stringify({ seeders: ["50+"], feature: ["10bit"], size: ["20-50"] }))).toEqual({ + seeders: ["50+"], + feature: ["10bit"], + size: ["20-50"], + }); + }); + + it("「更多筛选」收的是 MP 筛选条之外的维度", () => { + expect(MORE_DIMS).toEqual([ + "audioCodec", + "subtitle", + "dub", + "feature", + "year", + "category", + "size", + "seeders", + "hr", + ]); + expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([ + "站点", + "季/集", + "促销", + "编码", + "质量", + "HDR", + "分辨率", + "制作组", + ]); + }); + it("H&R 分面也记忆;旧版存的内容(没有 hr)照常读出,键不变", () => { expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] }); expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search"); diff --git a/apps/web/app/components/search/quick-filter-state.ts b/apps/web/app/components/search/quick-filter-state.ts index 7c0d5066..38db6280 100644 --- a/apps/web/app/components/search/quick-filter-state.ts +++ b/apps/web/app/components/search/quick-filter-state.ts @@ -7,18 +7,22 @@ import { useCallback, useEffect, useState } from "react"; export type QuickDim = keyof ReleaseQuickFacets; export type QuickSelection = Partial>; -/** 筛选条上的一个入口;「季/集」一个入口里分两节 */ -export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[] }; +/** + * 筛选条上的一个入口;「季/集」一个入口里分两节。 + * more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面 + * (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。 + */ +export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean }; + +const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({ + key: dim, + label, + dims: [{ dim, label }], + ...(more ? { more } : {}), +}); export const QUICK_GROUPS: QuickGroup[] = [ - { key: "resolution", label: "分辨率", dims: [{ dim: "resolution", label: "分辨率" }] }, - { key: "videoCodec", label: "编码", dims: [{ dim: "videoCodec", label: "编码" }] }, - { key: "releaseType", label: "版本", dims: [{ dim: "releaseType", label: "版本" }] }, - { 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: "制作组" }] }, + one("site", "站点"), { key: "seasonEpisode", label: "季/集", @@ -27,12 +31,43 @@ export const QUICK_GROUPS: QuickGroup[] = [ { dim: "episode", label: "集" }, ], }, + one("promotion", "促销"), + one("videoCodec", "编码"), + one("releaseType", "质量"), + one("hdr", "HDR"), + one("resolution", "分辨率"), + one("releaseGroup", "制作组"), + one("audioCodec", "音频", true), + one("subtitle", "字幕", true), + one("dub", "配音", true), + one("feature", "特性", true), + one("year", "年份", true), + one("category", "分类", true), + one("size", "大小", true), + one("seeders", "做种", true), + one("hr", "H&R", true), ]; +export const MORE_GROUPS = QUICK_GROUPS.filter((g) => g.more); +export const MORE_DIMS: QuickDim[] = MORE_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); + const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)); -/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */ -const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr", "hr"]; +/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */ +const PERSISTED: QuickDim[] = [ + "resolution", + "videoCodec", + "releaseType", + "promotion", + "hdr", + "hr", + "audioCodec", + "subtitle", + "dub", + "feature", + "size", + "seeders", +]; /** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */ export function formatQuickFilter(sel: QuickSelection): string | undefined { diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx index cb017f44..d9bd1592 100644 --- a/apps/web/app/components/search/release-results.tsx +++ b/apps/web/app/components/search/release-results.tsx @@ -184,6 +184,8 @@ export function ReleaseResults({ value={quick} onChange={setQuick} onClearAll={clearAll} + // 大小已有精确区间(大小与排序),不再给分档 + hide={["size"]} extraTags={ sizeTag ? [ diff --git a/apps/web/app/components/search/search2.test.ts b/apps/web/app/components/search/search2.test.ts index 86a9fb26..cdac0aca 100644 --- a/apps/web/app/components/search/search2.test.ts +++ b/apps/web/app/components/search/search2.test.ts @@ -75,7 +75,7 @@ describe("站点选择", () => { expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q"); // 快速筛选进 qf(同维度逗号分隔,值编码) expect(toApiFilters({ q: "x" }, f, "", { resolution: ["2160p", "1080p"], promotion: ["50%"], hdr: [] }).qf).toBe( - "resolution:2160p,1080p;promotion:50%25", + "promotion:50%25;resolution:2160p,1080p", ); expect(toApiFilters({ q: "x" }, f, "", {}).qf).toBeUndefined(); }); diff --git a/apps/web/app/routes/console/sites.$siteId.tsx b/apps/web/app/routes/console/sites.$siteId.tsx index 47973f3e..eee65307 100644 --- a/apps/web/app/routes/console/sites.$siteId.tsx +++ b/apps/web/app/routes/console/sites.$siteId.tsx @@ -56,33 +56,19 @@ const RESOLUTION: { value: ResolutionStatus; label: string }[] = [ { value: "ambiguous", label: "有歧义" }, { value: "unidentifiable", label: "无法识别" }, ]; -const SIZE = [ - { value: "lt5", label: "< 5 GB" }, - { value: "5to20", label: "5–20 GB" }, - { value: "20to60", label: "20–60 GB" }, - { value: "gt60", label: "> 60 GB" }, -]; const AGE = [ { value: "1", label: "24 小时内" }, { value: "7", label: "7 天内" }, { value: "30", label: "30 天内" }, ]; -const GB = 1024 ** 3; -/** 服务端不支持的维度(做种/大小/上传时间)在已加载结果里筛选,界面上写明;H&R 在快速筛选里(POLISH-1) */ -type LocalFilters = { seeding: string; size: string; age: string }; +/** + * 服务端不支持的维度(上传时间)在已加载结果里筛选,界面上写明。H&R(POLISH-1)、做种与大小(SEARCH-4) + * 在快速筛选里,按全部本地资源计数和筛选。 + */ +type LocalFilters = { age: string }; function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) { - if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false; - if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false; - if (f.size) { - const s = r.sizeBytes; - if (s === null) return false; - if (f.size === "lt5" && s >= 5 * GB) return false; - if (f.size === "5to20" && (s < 5 * GB || s >= 20 * GB)) return false; - if (f.size === "20to60" && (s < 20 * GB || s >= 60 * GB)) return false; - if (f.size === "gt60" && s < 60 * GB) return false; - } if (f.age) { const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null; if (t === null || now - t > Number(f.age) * 86_400_000) return false; @@ -151,7 +137,7 @@ export default function SiteWorkbenchPage() { const q = useDebounced(qInput.trim()); const [category, setCategory] = useState(""); const [resolution, setResolution] = useState(""); - const [local, setLocal] = useState({ seeding: "", size: "", age: "" }); + const [local, setLocal] = useState({ age: "" }); // 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求 const [quick, setQuick] = useQuickFilters("site"); const qf = formatQuickFilter(quick); @@ -202,7 +188,7 @@ export default function SiteWorkbenchPage() { setCategory(""); setQuick({}); setResolution(""); - setLocal({ seeding: "", size: "", age: "" }); + setLocal({ age: "" }); }; const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick)); @@ -377,21 +363,6 @@ export default function SiteWorkbenchPage() { > - setLocal((l) => ({ ...l, seeding: v }))} - options={[ - { value: "alive", label: "有人做种" }, - { value: "hot", label: "≥ 20 做种" }, - ]} - /> - setLocal((l) => ({ ...l, size: v }))} - options={SIZE} - /> {/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */} - + {/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */} + {coverage || refreshFailed ? ( @@ -429,7 +407,7 @@ export default function SiteWorkbenchPage() { } description={ all.length - ? "做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。" + ? "上传时间只筛选已加载的结果。加载更多或放宽条件。" : quickHidden ? "减少快速筛选条件再看。" : coverage?.liveError