feat(web): more-filters panel and MP ordering for quick filters (SEARCH-4)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
d9cf8f1c3a
commit
b15c06ad20
6 files changed
+307
-77
No files matched your search
@@ -1,5 +1,6 @@
|
|||||||
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
|
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 { type ReactNode, useState } from "react";
|
||||||
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
import { useIsMobile } from "~/components/files/use-mobile.ts";
|
||||||
import { Button } from "~/components/ui/button.tsx";
|
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 { cn } from "~/lib/utils.ts";
|
||||||
import {
|
import {
|
||||||
clearDims,
|
clearDims,
|
||||||
|
MORE_DIMS,
|
||||||
QUICK_GROUPS,
|
QUICK_GROUPS,
|
||||||
type QuickDim,
|
type QuickDim,
|
||||||
type QuickGroup,
|
type QuickGroup,
|
||||||
@@ -21,6 +23,8 @@ import {
|
|||||||
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
|
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
|
||||||
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
|
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
|
||||||
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
|
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
|
||||||
|
// SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R)
|
||||||
|
// 收进「更多筛选」:桌面一个宽弹层分节列出,手机一个抽屉。弹层与抽屉都是不透明实底(pg-float)。
|
||||||
|
|
||||||
/** 分面之外的已选条件(如大小区间),和分面标签排在一起 */
|
/** 分面之外的已选条件(如大小区间),和分面标签排在一起 */
|
||||||
export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void };
|
export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void };
|
||||||
@@ -52,8 +56,9 @@ export function QuickFilterBar({
|
|||||||
}) {
|
}) {
|
||||||
const mobile = useIsMobile();
|
const mobile = useIsMobile();
|
||||||
const [sheet, setSheet] = useState<QuickGroup | null>(null);
|
const [sheet, setSheet] = useState<QuickGroup | null>(null);
|
||||||
|
const [moreOpen, setMoreOpen] = useState(false);
|
||||||
const [asideOpen, setAsideOpen] = 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));
|
const dims = g.dims.filter((d) => !hide.includes(d.dim));
|
||||||
if (!dims.length) return false;
|
if (!dims.length) return false;
|
||||||
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
|
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
|
||||||
@@ -67,8 +72,12 @@ export function QuickFilterBar({
|
|||||||
) > 0
|
) > 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));
|
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({}));
|
const clearAll = onClearAll ?? (() => onChange({}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -100,26 +109,7 @@ export function QuickFilterBar({
|
|||||||
const dims = g.dims.map((d) => d.dim);
|
const dims = g.dims.map((d) => d.dim);
|
||||||
const n = selectedCount(value, dims);
|
const n = selectedCount(value, dims);
|
||||||
const chip = (
|
const chip = (
|
||||||
<button
|
<FilterChip key={g.key} label={g.label} count={n} onClick={mobile ? () => setSheet(g) : undefined} />
|
||||||
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 ? (
|
return mobile ? (
|
||||||
chip
|
chip
|
||||||
@@ -132,6 +122,27 @@ export function QuickFilterBar({
|
|||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{moreGroups.length ? (
|
||||||
|
mobile ? (
|
||||||
|
<FilterChip label="更多筛选" count={moreCount} onClick={() => setMoreOpen(true)} more />
|
||||||
|
) : (
|
||||||
|
<Popover open={moreOpen} onOpenChange={setMoreOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FilterChip label="更多筛选" count={moreCount} more />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent align="end" className="w-[min(44rem,calc(100vw-2rem))] p-0">
|
||||||
|
<MorePanel
|
||||||
|
groups={moreGroups}
|
||||||
|
facets={facets}
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
onClear={() => onChange(clearDims(value, moreDims))}
|
||||||
|
count={moreCount}
|
||||||
|
/>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{aside && !mobile ? <div className="flex shrink-0 items-center gap-1.5">{aside.content("inline")}</div> : null}
|
{aside && !mobile ? <div className="flex shrink-0 items-center gap-1.5">{aside.content("inline")}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -172,6 +183,33 @@ export function QuickFilterBar({
|
|||||||
</Sheet>
|
</Sheet>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{mobile && moreGroups.length ? (
|
||||||
|
<Sheet open={moreOpen} onOpenChange={setMoreOpen}>
|
||||||
|
<SheetContent side="bottom" className="max-h-[85dvh] gap-0 rounded-t-2xl">
|
||||||
|
<SheetHeader className="pb-2">
|
||||||
|
<SheetTitle>更多筛选</SheetTitle>
|
||||||
|
<SheetDescription className="text-meta">可多选,数字是选中后的条数</SheetDescription>
|
||||||
|
</SheetHeader>
|
||||||
|
<div className="min-h-0 overflow-y-auto px-4 pb-3">
|
||||||
|
<MorePanel groups={moreGroups} facets={facets} value={value} onChange={onChange} bare />
|
||||||
|
</div>
|
||||||
|
<SheetFooter className="flex-row gap-2 border-t border-border/60">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
disabled={!moreCount}
|
||||||
|
onClick={() => onChange(clearDims(value, moreDims))}
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</Button>
|
||||||
|
<Button className="flex-1" onClick={() => setMoreOpen(false)}>
|
||||||
|
完成
|
||||||
|
</Button>
|
||||||
|
</SheetFooter>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{mobile ? (
|
{mobile ? (
|
||||||
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
|
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
|
||||||
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
|
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
|
||||||
@@ -316,3 +354,133 @@ function GroupPanel({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 筛选条上的入口按钮;Popover 的 asChild 需要转发 ref 与事件,所以透传其余属性 */
|
||||||
|
function FilterChip({
|
||||||
|
label,
|
||||||
|
count,
|
||||||
|
more = false,
|
||||||
|
className,
|
||||||
|
...rest
|
||||||
|
}: React.ComponentProps<"button"> & { label: string; count: number; more?: boolean }) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
{...rest}
|
||||||
|
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]",
|
||||||
|
count
|
||||||
|
? "pill-selected text-foreground"
|
||||||
|
: "border-border text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{more ? <ListFilter className="h-3.5 w-3.5" /> : null}
|
||||||
|
{label}
|
||||||
|
{count ? (
|
||||||
|
<span className="min-w-4 rounded-full bg-primary px-1 text-center text-meta leading-4 text-primary-foreground tabular-nums">
|
||||||
|
{count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{more ? null : <ChevronDown className="h-3.5 w-3.5 opacity-70" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多筛选」:每个维度一节,选项是带计数的小按钮;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 (
|
||||||
|
<div className="flex max-h-[min(70dvh,32rem)] flex-col">
|
||||||
|
{bare ? null : (
|
||||||
|
<div className="flex items-center justify-between border-b border-border/60 px-4 py-2.5">
|
||||||
|
<span className="text-caption font-medium text-foreground">更多筛选</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={!count}
|
||||||
|
onClick={onClear}
|
||||||
|
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-50"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className={cn("grid min-h-0 gap-x-6 gap-y-4 overflow-y-auto", bare ? "" : "p-4 sm:grid-cols-2")}>
|
||||||
|
{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 (
|
||||||
|
<fieldset key={d.dim} className="m-0 min-w-0 border-0 p-0">
|
||||||
|
<legend className="mb-1.5 flex w-full items-center justify-between">
|
||||||
|
<span className="text-meta font-medium text-muted-foreground">{d.label}</span>
|
||||||
|
{n ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(clearDims(value, [d.dim]))}
|
||||||
|
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
清除
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</legend>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{all.map((o) => {
|
||||||
|
const on = chosen.includes(o.value);
|
||||||
|
const dead = o.count === 0 && !on;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={on}
|
||||||
|
disabled={dead}
|
||||||
|
onClick={() => onChange(toggleValue(value, d.dim, o.value))}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-8 max-w-full cursor-pointer items-center gap-1.5 rounded-full border px-3 text-caption transition-colors duration-micro md:h-7",
|
||||||
|
on
|
||||||
|
? "border-primary bg-primary text-primary-foreground"
|
||||||
|
: "border-border text-foreground hover:bg-foreground/[0.06]",
|
||||||
|
dead ? "cursor-default opacity-40 hover:bg-transparent" : "",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="min-w-0 truncate">{o.label}</span>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-meta tabular-nums",
|
||||||
|
on ? "text-primary-foreground/80" : "text-muted-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{o.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import {
|
import {
|
||||||
clearDims,
|
clearDims,
|
||||||
formatQuickFilter,
|
formatQuickFilter,
|
||||||
|
MORE_DIMS,
|
||||||
mergeLegacyFilters,
|
mergeLegacyFilters,
|
||||||
parseStored,
|
parseStored,
|
||||||
persistable,
|
persistable,
|
||||||
|
QUICK_GROUPS,
|
||||||
quickFilterStorageKey,
|
quickFilterStorageKey,
|
||||||
selectedCount,
|
selectedCount,
|
||||||
selectedTags,
|
selectedTags,
|
||||||
@@ -24,12 +26,23 @@ const empty = {
|
|||||||
releaseGroup: [],
|
releaseGroup: [],
|
||||||
season: [],
|
season: [],
|
||||||
episode: [],
|
episode: [],
|
||||||
|
audioCodec: [],
|
||||||
|
subtitle: [],
|
||||||
|
dub: [],
|
||||||
|
feature: [],
|
||||||
|
year: [],
|
||||||
|
category: [],
|
||||||
|
size: [],
|
||||||
|
seeders: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("快速筛选状态", () => {
|
describe("快速筛选状态", () => {
|
||||||
it("qf 按维度顺序拼接,值编码;空选择不带参数", () => {
|
it("qf 按维度顺序(对齐 MP 筛选条)拼接,值编码;空选择不带参数", () => {
|
||||||
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
|
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();
|
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
|
||||||
});
|
});
|
||||||
@@ -47,8 +60,8 @@ describe("快速筛选状态", () => {
|
|||||||
it("已选标签取分面里的选项名,取不到用原值", () => {
|
it("已选标签取分面里的选项名,取不到用原值", () => {
|
||||||
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
|
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
|
||||||
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
|
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
|
||||||
{ dim: "releaseType", value: "encode", dimLabel: "版本", label: "蓝光压制" },
|
|
||||||
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
|
{ 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"));
|
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)照常读出,键不变", () => {
|
it("H&R 分面也记忆;旧版存的内容(没有 hr)照常读出,键不变", () => {
|
||||||
expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] });
|
expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] });
|
||||||
expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search");
|
expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search");
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
export type QuickDim = keyof ReleaseQuickFacets;
|
export type QuickDim = keyof ReleaseQuickFacets;
|
||||||
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
|
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
|
||||||
|
|
||||||
/** 筛选条上的一个入口;「季/集」一个入口里分两节 */
|
/**
|
||||||
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[] = [
|
export const QUICK_GROUPS: QuickGroup[] = [
|
||||||
{ key: "resolution", label: "分辨率", dims: [{ dim: "resolution", label: "分辨率" }] },
|
one("site", "站点"),
|
||||||
{ 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: "制作组" }] },
|
|
||||||
{
|
{
|
||||||
key: "seasonEpisode",
|
key: "seasonEpisode",
|
||||||
label: "季/集",
|
label: "季/集",
|
||||||
@@ -27,12 +31,43 @@ export const QUICK_GROUPS: QuickGroup[] = [
|
|||||||
{ dim: "episode", label: "集" },
|
{ 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 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 */
|
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
|
||||||
export function formatQuickFilter(sel: QuickSelection): string | undefined {
|
export function formatQuickFilter(sel: QuickSelection): string | undefined {
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ export function ReleaseResults({
|
|||||||
value={quick}
|
value={quick}
|
||||||
onChange={setQuick}
|
onChange={setQuick}
|
||||||
onClearAll={clearAll}
|
onClearAll={clearAll}
|
||||||
|
// 大小已有精确区间(大小与排序),不再给分档
|
||||||
|
hide={["size"]}
|
||||||
extraTags={
|
extraTags={
|
||||||
sizeTag
|
sizeTag
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -75,7 +75,7 @@ describe("站点选择", () => {
|
|||||||
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
|
expect(toApiFilters({ tmdb: "movie:1" }, { ...f, sites: "" }, "")).not.toHaveProperty("q");
|
||||||
// 快速筛选进 qf(同维度逗号分隔,值编码)
|
// 快速筛选进 qf(同维度逗号分隔,值编码)
|
||||||
expect(toApiFilters({ q: "x" }, f, "", { resolution: ["2160p", "1080p"], promotion: ["50%"], hdr: [] }).qf).toBe(
|
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();
|
expect(toApiFilters({ q: "x" }, f, "", {}).qf).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -56,33 +56,19 @@ const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
|
|||||||
{ value: "ambiguous", label: "有歧义" },
|
{ value: "ambiguous", label: "有歧义" },
|
||||||
{ value: "unidentifiable", 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 = [
|
const AGE = [
|
||||||
{ value: "1", label: "24 小时内" },
|
{ value: "1", label: "24 小时内" },
|
||||||
{ value: "7", label: "7 天内" },
|
{ value: "7", label: "7 天内" },
|
||||||
{ value: "30", label: "30 天内" },
|
{ 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) {
|
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) {
|
if (f.age) {
|
||||||
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
|
const t = r.sourcePublishedAt ? new Date(r.sourcePublishedAt).getTime() : null;
|
||||||
if (t === null || now - t > Number(f.age) * 86_400_000) return false;
|
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 q = useDebounced(qInput.trim());
|
||||||
const [category, setCategory] = useState("");
|
const [category, setCategory] = useState("");
|
||||||
const [resolution, setResolution] = useState("");
|
const [resolution, setResolution] = useState("");
|
||||||
const [local, setLocal] = useState<LocalFilters>({ seeding: "", size: "", age: "" });
|
const [local, setLocal] = useState<LocalFilters>({ age: "" });
|
||||||
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
|
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
|
||||||
const [quick, setQuick] = useQuickFilters("site");
|
const [quick, setQuick] = useQuickFilters("site");
|
||||||
const qf = formatQuickFilter(quick);
|
const qf = formatQuickFilter(quick);
|
||||||
@@ -202,7 +188,7 @@ export default function SiteWorkbenchPage() {
|
|||||||
setCategory("");
|
setCategory("");
|
||||||
setQuick({});
|
setQuick({});
|
||||||
setResolution("");
|
setResolution("");
|
||||||
setLocal({ seeding: "", size: "", age: "" });
|
setLocal({ age: "" });
|
||||||
};
|
};
|
||||||
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
|
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
|
||||||
|
|
||||||
@@ -377,21 +363,6 @@ export default function SiteWorkbenchPage() {
|
|||||||
>
|
>
|
||||||
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
|
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
|
||||||
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
|
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
|
||||||
<FilterSelect
|
|
||||||
label="做种"
|
|
||||||
value={local.seeding}
|
|
||||||
onChange={(v) => setLocal((l) => ({ ...l, seeding: v }))}
|
|
||||||
options={[
|
|
||||||
{ value: "alive", label: "有人做种" },
|
|
||||||
{ value: "hot", label: "≥ 20 做种" },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
|
||||||
label="大小"
|
|
||||||
value={local.size}
|
|
||||||
onChange={(v) => setLocal((l) => ({ ...l, size: v }))}
|
|
||||||
options={SIZE}
|
|
||||||
/>
|
|
||||||
<FilterSelect
|
<FilterSelect
|
||||||
label="上传"
|
label="上传"
|
||||||
value={local.age}
|
value={local.age}
|
||||||
@@ -401,7 +372,14 @@ export default function SiteWorkbenchPage() {
|
|||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
|
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
|
||||||
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} hideClear />
|
{/* 分类已有上方的站点分类下拉(与 MP 站点资源一致),不再给分面 */}
|
||||||
|
<QuickFilterBar
|
||||||
|
facets={pages[0]?.facets}
|
||||||
|
value={quick}
|
||||||
|
onChange={setQuick}
|
||||||
|
hide={["site", "category"]}
|
||||||
|
hideClear
|
||||||
|
/>
|
||||||
|
|
||||||
{coverage || refreshFailed ? (
|
{coverage || refreshFailed ? (
|
||||||
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
|
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
|
||||||
@@ -429,7 +407,7 @@ export default function SiteWorkbenchPage() {
|
|||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
all.length
|
all.length
|
||||||
? "做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
? "上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||||
: quickHidden
|
: quickHidden
|
||||||
? "减少快速筛选条件再看。"
|
? "减少快速筛选条件再看。"
|
||||||
: coverage?.liveError
|
: coverage?.liveError
|
||||||
|
|||||||
Reference in new issue
Block a user