feat(search): 筛选选项只列结果里有的值,季集合并成一个维度放最前

对照 MP useTorrentFilter:分面选项按实际结果生成,计数为 0 的不再列出(已选的值仍列出,可删除);
新增季集维度 seasonEpisode(MP season_episode 写法 `S01` / `S01-S02` / `S02 E14` / `S01 E25-E48`),
整季在前、单集在后、季号新的在前,在筛选条最前。旧的季 / 集维度保留(旧链接仍生效,以标签列出)。
搜索结果页与站点资源 Tab 共用这套口径。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 15:39:32 +08:00
1 parent 18ee466664
commit f6d976a963
7 files changed
+181 -91

No files matched your search

@@ -20,7 +20,8 @@ import {
} from "./quick-filter-state.ts";
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
// 选项只列结果里有的值(服务端不给 0 计数的选项),带计数(按其它维度的已选条件);已选但当前为 0 的置灰;
// 已选条件以可移除标签列出,只有一个「清除」。季集在最前(整季在前、单集在后)。
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
// SEARCH-4:对齐 MP 后维度变多,常用的留在筛选条上,其余(音频、字幕、配音、特性、年份、分类、大小、做种、H&R)
@@ -62,7 +63,7 @@ export function QuickFilterBar({
const dims = g.dims.filter((d) => !hide.includes(d.dim));
if (!dims.length) return false;
const opts = dims.flatMap((d) => facets?.[d.dim] ?? []);
// 站点只有一个时不用筛;季/集只在有剧集资源时出现
// 站点只有一个时不用筛;季集只在有剧集资源时出现
if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false;
return (
opts.length > 0 ||
@@ -16,6 +16,7 @@ import {
// SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位
const empty = {
seasonEpisode: [],
resolution: [],
videoCodec: [],
releaseType: [],
@@ -41,9 +42,11 @@ describe("快速筛选状态", () => {
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
"hdr:DV;resolution:2160p;releaseGroup:A%2CB",
);
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"] })).toBe(
"site:s;audioCodec:TrueHD;seeders:0",
expect(formatQuickFilter({ seeders: ["0"], audioCodec: ["TrueHD"], site: ["s"], seasonEpisode: ["S01"] })).toBe(
"seasonEpisode:S01;site:s;audioCodec:TrueHD;seeders:0",
);
// 旧的季 / 集维度排在最后,仍会带上
expect(formatQuickFilter({ season: ["2"], resolution: ["2160p"] })).toBe("resolution:2160p;season:2");
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
});
@@ -57,11 +60,16 @@ describe("快速筛选状态", () => {
expect(selectedCount(clearDims(s, ["season", "episode"]))).toBe(1);
});
it("已选标签取分面里的选项名,取不到用原值", () => {
const facets = { ...empty, releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }] };
expect(selectedTags({ releaseType: ["encode"], season: ["2"] }, facets)).toEqual([
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
it("已选标签取分面里的选项名,取不到用原值;季集在最前,旧的季 / 集在最后", () => {
const facets = {
...empty,
releaseType: [{ value: "encode", label: "蓝光压制", count: 3 }],
seasonEpisode: [{ value: "S02 E14", label: "S02 E14", count: 1 }],
};
expect(selectedTags({ releaseType: ["encode"], season: ["2"], seasonEpisode: ["S02 E14"] }, facets)).toEqual([
{ dim: "seasonEpisode", value: "S02 E14", dimLabel: "季集", label: "S02 E14" },
{ dim: "releaseType", value: "encode", dimLabel: "质量", label: "蓝光压制" },
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
]);
});
@@ -86,7 +94,7 @@ describe("快速筛选状态", () => {
});
});
it("「更多筛选」收的是 MP 筛选条之外的维度", () => {
it("「更多筛选」收的是 MP 筛选条之外的维度;季集放最前", () => {
expect(MORE_DIMS).toEqual([
"audioCodec",
"subtitle",
@@ -99,8 +107,8 @@ describe("快速筛选状态", () => {
"hr",
]);
expect(QUICK_GROUPS.filter((g) => !g.more).map((g) => g.label)).toEqual([
"季集",
"站点",
"季/集",
"促销",
"编码",
"质量",
@@ -8,9 +8,10 @@ export type QuickDim = keyof ReleaseQuickFacets;
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
/**
* 筛选条上的一个入口;「季/集」一个入口里分两节。
* 筛选条上的一个入口。
* more = 收进「更多筛选」(SEARCH-4):常用维度按 MP 搜索结果筛选条的顺序放在前面
* (站点、季集、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
* (季集放最前,再是站点、促销、视频编码、质量、分辨率、制作组;HDR 在 MP 里并在「质量」,这里单列紧跟其后)。
* 季集是一个维度(MP 的 season_episode):整季在前、单集在后,服务端排好序。
*/
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[]; more?: boolean };
@@ -22,15 +23,8 @@ const one = (dim: QuickDim, label: string, more?: boolean): QuickGroup => ({
});
export const QUICK_GROUPS: QuickGroup[] = [
one("seasonEpisode", "季集"),
one("site", "站点"),
{
key: "seasonEpisode",
label: "季/集",
dims: [
{ dim: "season", label: "季" },
{ dim: "episode", label: "集" },
],
},
one("promotion", "促销"),
one("videoCodec", "编码"),
one("releaseType", "质量"),
@@ -51,7 +45,16 @@ export const QUICK_GROUPS: QuickGroup[] = [
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 LEGACY_DIMS: { dim: QuickDim; label: string }[] = [
{ dim: "season", label: "季" },
{ dim: "episode", label: "集" },
];
const DIM_ORDER: QuickDim[] = [
...QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim)),
...LEGACY_DIMS.map((d) => d.dim),
];
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集、年份、分类随搜索变化,不记 */
const PERSISTED: QuickDim[] = [
@@ -97,7 +100,9 @@ export function selectedTags(
sel: QuickSelection,
facets: ReleaseQuickFacets | undefined,
): { dim: QuickDim; value: string; dimLabel: string; label: string }[] {
const dimLabel = new Map(QUICK_GROUPS.flatMap((g) => g.dims.map((d) => [d.dim, d.label] as const)));
const dimLabel = new Map(
[...QUICK_GROUPS.flatMap((g) => g.dims), ...LEGACY_DIMS].map((d) => [d.dim, d.label] as const),
);
return DIM_ORDER.flatMap((dim) =>
(sel[dim] ?? []).map((value) => ({
dim,