merge: fix/m-search-filter

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 16:01:46 +08:00
commit 88f98386e8
21 files changed
+1955 -140

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,
@@ -57,6 +57,8 @@ import {
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
// 按作品搜时,服务端判定不属于这部作品的资源(对照 MP match_torrent)不参与筛选与分面,
// 在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
@@ -247,14 +249,35 @@ export function ReleaseResults({
<EmptyState
plain
icon={PackageSearch}
title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
title={
filtered
? "没有符合筛选条件的资源"
: first?.unmatchedTotal
? "没有找到这部作品的资源"
: "所选站点都没有找到资源"
}
description={
filtered
? "放宽筛选条件再看。"
: first?.unmatchedTotal
? "下面是按名字搜到、但可能不是这部作品的资源。"
: "换站点或换关键词再搜,或到站点页检查站点状态。"
}
className="py-12"
/>
) : (
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
)}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
{first?.unmatchedTotal ? (
<UnmatchedReleases
key={JSON.stringify(api)}
rows={first.unmatched ?? []}
total={first.unmatchedTotal}
onOpen={setSelected}
onDownload={setDl}
/>
) : null}
</div>
{first ? (
@@ -285,6 +308,42 @@ export function ReleaseResults({
);
}
/** 「另有 N 条可能不是这部作品」:默认收起,展开后与上面的资源行一样可查看、下载 */
export function UnmatchedReleases({
rows,
total,
onOpen,
onDownload,
defaultOpen = false,
}: {
rows: SearchRelease[];
total: number;
onOpen: (r: SearchRelease) => void;
onDownload: (r: SearchRelease) => void;
defaultOpen?: boolean;
}) {
const [open, setOpen] = useState(defaultOpen);
if (!total) return null;
return (
<div className="border-t border-border/60">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
className="flex w-full cursor-pointer items-center gap-1.5 px-4 py-2.5 text-left text-caption text-muted-foreground transition-colors duration-micro hover:bg-foreground/[0.03] hover:text-foreground"
>
<ChevronDown className={cn("h-4 w-4 transition-transform duration-micro", open && "rotate-180")} />
另有 {total} 条可能不是这部作品
</button>
{open ? (
<div className="border-t border-border/60">
<ReleaseList rows={rows} onOpen={onOpen} onDownload={onDownload} />
</div>
) : null}
</div>
);
}
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
const [open, setOpen] = useState(false);
@@ -1,8 +1,11 @@
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import type { MediaServerInventory, WorkInventory } from "@umt/contracts";
import type { MediaServerInventory, SearchRelease, WorkInventory } from "@umt/contracts";
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router";
import { describe, expect, it } from "vitest";
import { parseWorkRef, type ReleaseFilterState, toApiFilters } from "./release-results.tsx";
import { parseWorkRef, type ReleaseFilterState, toApiFilters, UnmatchedReleases } from "./release-results.tsx";
import { resolvePick } from "./site-picker-dialog.tsx";
import { inventoryBadge, mediaServerSummary, releaseSearchPath, slotLine, workHref, workRef } from "./work-card.tsx";
@@ -127,3 +130,70 @@ describe("订阅入口位置", () => {
}
});
});
describe("可能不是这部作品的资源", () => {
const rel = (key: string, title: string): SearchRelease => ({
key,
sourceReleaseId: null,
siteId: ID,
siteName: "馒头",
siteReleaseId: key,
title,
subtitle: null,
category: { id: null, name: "音乐" },
sizeBytes: null,
seeders: 3,
leechers: 0,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
free: false,
hr: false,
uploadedAt: null,
detailUrl: "http://x/",
observedAt: "2026-10-06T00:00:00.000Z",
parsed: {
titleCn: null,
titleEn: null,
year: 2023,
season: null,
episodes: null,
resolution: "1080p",
source: "BluRay-Disc",
videoCodec: null,
hdr: [],
releaseGroup: null,
},
mediaEntityId: null,
entityMatch: null,
entity: null,
inventory: null,
ruleVerdict: null,
});
const rows = [rel("a", "Channel U ESCL-6057~9 2023 BDMV 1080p FLAC-@U2")];
const render = (defaultOpen: boolean, total = 1) =>
renderToStaticMarkup(
createElement(
MemoryRouter,
null,
createElement(UnmatchedReleases, { rows, total, onOpen: () => {}, onDownload: () => {}, defaultOpen }),
),
);
it("默认折叠成一行「另有 N 条可能不是这部作品」,不列资源", () => {
const html = render(false);
expect(html).toContain("另有 1 条可能不是这部作品");
expect(html).toContain('aria-expanded="false"');
expect(html).not.toContain("Channel U");
});
it("展开后列出资源,仍有下载按钮", () => {
const html = render(true);
expect(html).toContain('aria-expanded="true"');
expect(html).toContain("Channel U ESCL-6057~9 2023 BDMV 1080p FLAC-@U2");
expect(html).toContain("下载");
});
it("没有时不渲染", () => {
expect(render(false, 0)).toBe("");
});
});