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