Merge branch 'search3'

Co-Authored-By: Claude Fable 5.1 <[email protected]>

# Conflicts:
#	docs/plan/00-first-version-plan.md
This commit is contained in:
root committed 2026-09-29 20:52:51 +08:00
commit 4c7375b52f
20 files changed
+1273 -81

No files matched your search

+27 -1
View File
@@ -11,7 +11,7 @@ import type {
SearchSiteCoverage,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle, parseRelease } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam, parseRelease } from "@umt/domain";
import type { SourcesRuntime } from "../sources-identity/runtime.ts";
import { upsertSiteRelease } from "../sources-identity/service/snapshot.ts";
import { raceAbort } from "./media.ts";
@@ -286,6 +286,7 @@ function toBase(site: Pick<SiteRow, "id" | "name">, r: AdapterRelease, sourceRel
titleEn: p.titleEn ?? null,
year: p.year ?? null,
season: p.season ?? null,
seasonEnd: p.seasonEnd ?? null,
episodes: p.episodes ? { start: p.episodes.start, end: p.episodes.end } : null,
resolution: p.resolution ?? null,
source: p.source ?? null,
@@ -336,6 +337,31 @@ export function filterReleases(items: BaseRelease[], q: ReleaseSearchQuery): Bas
});
}
/** 搜索结果 → 快速筛选口径的输入 */
export const searchFacetInput = (r: BaseRelease): FacetInput => ({
siteId: r.siteId,
siteName: r.siteName,
resolution: r.parsed.resolution,
source: r.parsed.source,
videoCodec: r.parsed.videoCodec,
hdr: r.parsed.hdr,
releaseGroup: r.parsed.releaseGroup,
season: r.parsed.season,
seasonEnd: r.parsed.seasonEnd ?? null,
episodes: r.parsed.episodes,
promotion: {
type: r.promotion.type,
downloadFactor: r.promotion.downloadFactor,
uploadFactor: r.promotion.uploadFactor,
expiresAt: r.promotion.expiresAt,
},
});
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
export function quickFilter(items: BaseRelease[], q: ReleaseSearchQuery, now: number) {
return applyFacets(filterReleases(items, q), searchFacetInput, parseFacetParam(q.qf), { now });
}
const time = (s: string | null) => (s ? Date.parse(s) : null);
/** 空值恒排最后;default = 做种数降序,同分发布时间新→旧 */
+4 -3
View File
@@ -34,7 +34,7 @@ import type { ModuleApp, ServerContext } from "../types.ts";
import { matchActions } from "./actions.ts";
import { InternalDispatcher } from "./dispatch.ts";
import { localEntities, raceAbort, searchLocalMedia, TmdbSearch, withInventory } from "./media.ts";
import { decorate, facets, filterReleases, ReleaseFanout, sortReleases } from "./releases.ts";
import { decorate, facets, quickFilter, ReleaseFanout, sortReleases } from "./releases.ts";
import {
buildWorks,
detailSeasons,
@@ -278,7 +278,8 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
const terms = await releaseTerms(q);
const hit = await fanout.search(terms, await resolveSites(q.sites), { refresh: q.refresh === "true" });
const all = hit.fanout.items;
const filtered = sortReleases(filterReleases(all, q), q.sort, q.order);
const quick = quickFilter(all, q, d.now());
const filtered = sortReleases(quick.items, q.sort, q.order);
const offset = decodeCursor(q.cursor);
const pageItems = filtered.slice(offset, offset + q.limit);
const next = offset + q.limit < filtered.length ? encodeCursor(offset + q.limit) : null;
@@ -290,7 +291,7 @@ export function registerSearchRoutes(app: ModuleApp, ctx: ServerContext, d: Sear
nextCursor: next,
total: filtered.length,
unfilteredTotal: all.length,
facets: facets(all),
facets: { ...facets(all), ...quick.facets },
partial: failed.length > 0,
sites: hit.fanout.sites,
failedSites: failed,
@@ -1,12 +1,12 @@
import type {
ReleaseOrigin,
WorkbenchCoverage,
WorkbenchRelease,
WorkbenchReleaseDetail,
WorkbenchReleasePage,
WorkbenchReleaseQuery,
} from "@umt/contracts";
import { type SiteRow, sql } from "@umt/db";
import { normalizeTitle } from "@umt/domain";
import { applyFacets, type FacetInput, normalizeTitle, parseFacetParam } from "@umt/domain";
import { ApiException } from "../../../http/errors.ts";
import { decodeCursor, page, toApi } from "../rows.ts";
import { type SourcesRuntime, siteSearchConcurrency } from "../runtime.ts";
@@ -103,6 +103,46 @@ function toWorkbench(row: ReleaseJoinRow): WorkbenchRelease {
};
}
/** 快速筛选用的轻量行:只取解析结果与促销 */
interface LightRow {
id: string;
parsed: unknown;
promotion: unknown;
promotion_expires_at: Date | null;
}
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
/** 资源表行(parsed = ParsedRelease JSON,promotion = {kind, raw:{downloadFactor, uploadFactor}}) → 快速筛选口径 */
export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">): FacetInput {
const p = (r.parsed ?? {}) as Record<string, unknown>;
const promo = (r.promotion ?? {}) as { kind?: unknown; expiresAt?: unknown; raw?: unknown };
const raw = (promo.raw ?? {}) as { downloadFactor?: unknown; uploadFactor?: unknown };
const eps = p.episodes as { start?: unknown; end?: unknown } | undefined;
const start = num(eps?.start);
const group = p.releaseGroup as { name?: unknown } | undefined;
const expires = r.promotion_expires_at ?? (typeof promo.expiresAt === "string" ? new Date(promo.expiresAt) : null);
return {
siteId: site.id,
siteName: site.name,
resolution: str(p.resolution),
source: str(p.source),
videoCodec: str(p.videoCodec),
hdr: Array.isArray(p.hdr) ? p.hdr.filter((h): h is string => typeof h === "string") : [],
releaseGroup: str(group?.name),
season: num(p.season),
seasonEnd: num(p.seasonEnd),
episodes: start !== null ? { start, end: num(eps?.end) ?? start } : null,
promotion: {
type: str(promo.kind),
downloadFactor: num(raw.downloadFactor),
uploadFactor: num(raw.uploadFactor),
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
},
};
}
function releaseSelect(rt: SourcesRuntime) {
return rt.ctx.db
.selectFrom("source_release as r")
@@ -122,7 +162,7 @@ export async function listSiteReleases(
rt: SourcesRuntime,
siteId: string,
q: WorkbenchReleaseQuery,
): Promise<{ items: WorkbenchRelease[]; nextCursor: string | null; coverage: WorkbenchCoverage }> {
): Promise<WorkbenchReleasePage> {
const site = await rt.ctx.db.selectFrom("site").selectAll().where("id", "=", siteId).executeTakeFirst();
if (!site) throw new ApiException("not_found", "站点不存在");
const snapAt = await lastSnapshotAt(rt, site.id);
@@ -130,8 +170,18 @@ export async function listSiteReleases(
const stale = !snapAt || rt.now().getTime() - snapAt.getTime() > periodMs * 2;
const offset = decodeCursor(q.cursor);
const run = async () => {
let query = releaseSelect(rt).where("r.site_id", "=", site.id);
// 快速筛选(SEARCH-3):先按 q / 分类 / 促销 / 识别状态取出全部候选的轻量字段,在内存里算分面并筛选,
// 再只为当前页取完整行。只读本地资源表,不触发站点请求。
const run = async (): Promise<LightRow[]> => {
let query = rt.ctx.db
.selectFrom("source_release as r")
.select([
"r.id",
sql<unknown>`r.parsed`.as("parsed"),
sql<unknown>`r.promotion`.as("promotion"),
"r.promotion_expires_at",
])
.where("r.site_id", "=", site.id);
if (q.q) {
const like = `%${q.q.replace(/[\\%_]/g, (m) => `\\${m}`)}%`;
query = query.where((eb) =>
@@ -149,9 +199,7 @@ export async function listSiteReleases(
return (await query
.orderBy(sql`coalesce(r.source_published_at, r.first_seen_at)`, "desc")
.orderBy("r.id")
.offset(offset)
.limit(q.limit + 1)
.execute()) as unknown as ReleaseJoinRow[];
.execute()) as unknown as LightRow[];
};
let rows = await run();
@@ -169,10 +217,23 @@ export async function listSiteReleases(
if (live) rows = await run();
}
}
const pg = page(rows, offset, q.limit);
const quick = applyFacets(rows, (r) => lightFacetInput(r, site), parseFacetParam(q.qf), {
now: rt.now().getTime(),
});
const slice = quick.items.slice(offset, offset + q.limit + 1);
const ids = slice.map((r) => r.id);
const full = ids.length
? ((await releaseSelect(rt).where("r.id", "in", ids).execute()) as unknown as ReleaseJoinRow[])
: [];
const byId = new Map(full.map((r) => [r.id as string, r]));
const ordered = ids.map((id) => byId.get(id)).filter((r): r is ReleaseJoinRow => !!r);
const pg = page(ordered, offset, q.limit);
return {
items: pg.items.map(toWorkbench),
nextCursor: pg.nextCursor,
total: quick.items.length,
unfilteredTotal: rows.length,
facets: quick.facets,
coverage: {
siteEnabled: site.enabled,
lastSnapshotAt: snapAt?.toISOString() ?? null,
+84
View File
@@ -396,6 +396,90 @@ describe.skipIf(!up)("全局搜索", () => {
expect(p2.nextCursor).toBeNull();
});
it("快速筛选 qf(SEARCH-3):分面计数、同维度 OR / 跨维度 AND、分页一致", async () => {
const q = (qf: string, extra = "") =>
req(`/search/releases?q=Dune&qf=${encodeURIComponent(qf)}${extra}`).then((r) => r.body.data);
const counts = (opts: Loose[]) => Object.fromEntries(opts.map((o) => [o.value, o.count]));
const ids = (d: Loose) => d.items.map((i: Loose) => i.siteReleaseId).sort();
let d = await q("");
expect(d.total).toBe(3);
expect(counts(d.facets.resolution)).toEqual({ "2160p": 2, "1080p": 1, "720p": 0 });
expect(counts(d.facets.videoCodec)).toEqual({ "H.265": 2, "H.264": 1, AV1: 0 });
expect(counts(d.facets.releaseType)).toEqual({ disc: 0, remux: 1, encode: 0, webdl: 2, hdtv: 0 });
expect(counts(d.facets.site)).toEqual({ [sites.nexus]: 2, [sites.mteam]: 1 });
expect(d.facets.site.find((f: Loose) => f.value === sites.nexus).label).toBe("观众");
expect(counts(d.facets.releaseGroup)).toEqual({ FraMeSToR: 1, GRP: 1, FLUX: 1 });
expect(d.facets.hdr.find((f: Loose) => f.value === "SDR").count).toBe(2);
expect(d.facets.season).toEqual([]);
// 旧分面仍在
expect(d.facets.resolutions.length).toBe(2);
d = await q("resolution:2160p");
expect(ids(d)).toEqual(["812345", "990001"]);
expect(d).toMatchObject({ total: 2, unfilteredTotal: 3 });
// 本维度其它选项仍按其它条件计数
expect(counts(d.facets.resolution)).toMatchObject({ "2160p": 2, "1080p": 1 });
expect(counts(d.facets.releaseType)).toMatchObject({ remux: 1, webdl: 1 });
d = await q("resolution:2160p;releaseType:webdl");
expect(ids(d)).toEqual(["990001"]);
d = await q(`resolution:2160p,1080p;site:${sites.nexus}`);
expect(ids(d)).toEqual(["812345", "900001"]);
d = await q("releaseGroup:flux,grp");
expect(ids(d)).toEqual(["900001", "990001"]);
d = await q("hdr:DV");
expect(ids(d)).toEqual(["812345"]);
d = await q("videoCodec:AV1");
expect(d.items).toEqual([]);
expect(d.total).toBe(0);
// 与旧参数叠加(AND)
d = await q("resolution:2160p", "&source=REMUX");
expect(ids(d)).toEqual(["812345"]);
// 分页:同一筛选下逐页取完 = 一次取完,且排序一致
const whole = await q("videoCodec:H.265,H.264", "&sort=size&limit=50");
const seen: string[] = [];
let cursor: string | null = null;
do {
const p: Loose = await q("videoCodec:H.265,H.264", `&sort=size&limit=1${cursor ? `&cursor=${cursor}` : ""}`);
expect(p.total).toBe(3);
expect(p.facets.videoCodec).toEqual(whole.facets.videoCodec);
seen.push(...p.items.map((i: Loose) => i.siteReleaseId));
cursor = p.nextCursor;
} while (cursor);
expect(seen).toEqual(whole.items.map((i: Loose) => i.siteReleaseId));
});
it("站点资源列表的分面基于本地数据,不请求站点", async () => {
const before = [nexusSearches(), mteamSearches()];
const list = (qf: string) =>
req(`/sites/${sites.nexus}/releases?q=Dune&live=false&qf=${encodeURIComponent(qf)}`).then((r) => r.body.data);
let d = await list("");
expect(d.total).toBe(2);
expect(d.unfilteredTotal).toBe(2);
expect(d.facets.site).toEqual([{ value: sites.nexus, label: "观众", count: 2 }]);
expect(d.facets.resolution.find((f: Loose) => f.value === "2160p").count).toBe(1);
expect(d.facets.releaseType.find((f: Loose) => f.value === "remux").count).toBe(1);
d = await list("resolution:1080p");
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 });
expect(d.facets.releaseGroup.map((f: Loose) => [f.value, f.count])).toEqual([
["FraMeSToR", 0],
["GRP", 1],
]);
// 分页
const p1 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1`).then((r) => r.body.data);
expect(p1.items.length).toBe(1);
const p2 = await req(`/sites/${sites.nexus}/releases?q=Dune&live=false&limit=1&cursor=${p1.nextCursor}`).then(
(r) => r.body.data,
);
expect(p2.items.length).toBe(1);
expect(p2.items[0].id).not.toBe(p1.items[0].id);
expect(p2.nextCursor).toBeNull();
expect([nexusSearches(), mteamSearches()]).toEqual(before);
});
it("H&R 筛选:true 只要明确 H&R;false 只排除明确 H&R(unknown 保留)", () => {
const base = (id: string, hr: boolean | "unknown") => ({ ...sample, key: id, siteReleaseId: id, hr });
const items = [base("a", true), base("b", false), base("c", "unknown")];
+2
View File
@@ -14,6 +14,8 @@ export type ReleaseFilters = {
category?: string;
promotion?: string;
resolution?: ResolutionStatus;
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`;只筛本地数据 */
qf?: string;
};
export const releaseKeys = {
+2
View File
@@ -50,6 +50,8 @@ export type ReleaseSearchFilters = {
siteFilter?: string;
resolution?: string;
source?: string;
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值` */
qf?: string;
/** 字节 */
minSize?: number;
maxSize?: number;
@@ -0,0 +1,251 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { Check, ChevronDown, X } from "lucide-react";
import { useState } from "react";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import { Button } from "~/components/ui/button.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn } from "~/lib/utils.ts";
import {
clearDims,
QUICK_GROUPS,
type QuickDim,
type QuickGroup,
type QuickSelection,
selectedCount,
selectedTags,
toggleValue,
} from "./quick-filter-state.ts";
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,可「清除全部」。
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
export function QuickFilterBar({
facets,
value,
onChange,
hide = [],
className,
}: {
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
onChange: (next: QuickSelection) => void;
/** 不显示的维度(站点资源 Tab 不显示站点) */
hide?: QuickDim[];
className?: string;
}) {
const mobile = useIsMobile();
const [sheet, setSheet] = useState<QuickGroup | null>(null);
const groups = 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] ?? []);
// 站点只有一个时不用筛;季/集只在有剧集资源时出现
if (g.key === "site" && opts.length <= 1 && !selectedCount(value, ["site"])) return false;
return (
opts.length > 0 ||
selectedCount(
value,
dims.map((d) => d.dim),
) > 0
);
});
const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim));
if (!groups.length && !tags.length) return null;
return (
<div className={cn("mb-3 min-w-0 space-y-2", className)}>
<div
role="toolbar"
aria-label="快速筛选"
className="-mx-1 flex min-w-0 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
>
{groups.map((g) => {
const dims = g.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
const chip = (
<button
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 ? (
chip
) : (
<Popover key={g.key}>
<PopoverTrigger asChild>{chip}</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-0">
<GroupPanel group={g} facets={facets} value={value} onChange={onChange} />
</PopoverContent>
</Popover>
);
})}
</div>
{tags.length ? (
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
{tags.map((t) => (
<button
key={`${t.dim}:${t.value}`}
type="button"
onClick={() => onChange(toggleValue(value, t.dim, t.value))}
aria-label={`移除筛选 ${t.dimLabel} ${t.label}`}
className="inline-flex h-7 cursor-pointer items-center gap-1 rounded-full bg-foreground/[0.06] pr-1.5 pl-2.5 text-meta text-foreground transition-colors duration-micro hover:bg-foreground/[0.1]"
>
<span className="text-muted-foreground">{t.dimLabel}</span>
<span className="max-w-40 truncate">{t.label}</span>
<X className="h-3 w-3 text-muted-foreground" />
</button>
))}
<Button variant="ghost" size="sm" className="h-7" onClick={() => onChange({})}>
清除全部
</Button>
</div>
) : null}
{mobile ? (
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
<SheetHeader className="pb-2">
<SheetTitle>{sheet?.label}</SheetTitle>
<SheetDescription className="text-meta">可多选,数字是选中后的条数</SheetDescription>
</SheetHeader>
{sheet ? (
<div className="min-h-0 overflow-y-auto px-2 pb-2">
<GroupPanel group={sheet} facets={facets} value={value} onChange={onChange} bare />
</div>
) : null}
<SheetFooter className="flex-row gap-2 border-t border-border/60">
<Button
variant="outline"
className="flex-1"
disabled={
!sheet ||
!selectedCount(
value,
sheet.dims.map((d) => d.dim),
)
}
onClick={() =>
sheet &&
onChange(
clearDims(
value,
sheet.dims.map((d) => d.dim),
),
)
}
>
清除
</Button>
<Button className="flex-1" onClick={() => setSheet(null)}>
完成
</Button>
</SheetFooter>
</SheetContent>
</Sheet>
) : null}
</div>
);
}
/** 一个入口的多选列表:每项带计数;0 计数且未选时置灰不可选 */
function GroupPanel({
group,
facets,
value,
onChange,
bare = false,
}: {
group: QuickGroup;
facets: ReleaseQuickFacets | undefined;
value: QuickSelection;
onChange: (next: QuickSelection) => void;
/** 抽屉里不显示标题栏(抽屉有自己的标题和清除) */
bare?: boolean;
}) {
const dims = group.dims.map((d) => d.dim);
const n = selectedCount(value, dims);
return (
<div className="flex max-h-[min(60dvh,24rem)] flex-col">
{bare ? null : (
<div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
<span className="text-caption font-medium text-foreground">{group.label}</span>
<button
type="button"
disabled={!n}
onClick={() => onChange(clearDims(value, dims))}
className="cursor-pointer text-meta text-muted-foreground hover:text-foreground disabled:cursor-default disabled:opacity-50"
>
清除
</button>
</div>
)}
<div className={cn("min-h-0 overflow-y-auto", bare ? "" : "p-1")}>
{group.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;
return (
<fieldset key={d.dim} className="m-0 border-0 p-0">
{group.dims.length > 1 ? (
<legend className="px-2 pt-2 pb-1 text-meta text-muted-foreground">{d.label}</legend>
) : (
<legend className="sr-only">{d.label}</legend>
)}
{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(
"flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-left text-sm transition-colors duration-micro md:py-1.5",
on ? "text-foreground" : "text-foreground/90",
dead ? "cursor-default opacity-40" : "hover:bg-foreground/[0.06]",
)}
>
<span
aria-hidden
className={cn(
"grid h-4 w-4 shrink-0 place-items-center rounded-sm border",
on ? "border-primary bg-primary text-primary-foreground" : "border-border",
)}
>
{on ? <Check className="h-3 w-3" /> : null}
</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
<span className="text-meta text-muted-foreground tabular-nums">{o.count}</span>
</button>
);
})}
</fieldset>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import {
clearDims,
formatQuickFilter,
parseStored,
persistable,
quickFilterStorageKey,
selectedCount,
selectedTags,
toggleValue,
} from "./quick-filter-state.ts";
// SEARCH-3 快速筛选:qf 参数、选择切换、已选标签、本机记忆只存固定档位
const empty = {
resolution: [],
videoCodec: [],
releaseType: [],
site: [],
promotion: [],
hdr: [],
releaseGroup: [],
season: [],
episode: [],
};
describe("快速筛选状态", () => {
it("qf 按维度顺序拼接,值编码;空选择不带参数", () => {
expect(formatQuickFilter({ hdr: ["DV"], resolution: ["2160p"], releaseGroup: ["A,B"] })).toBe(
"resolution:2160p;hdr:DV;releaseGroup:A%2CB",
);
expect(formatQuickFilter({ resolution: [] })).toBeUndefined();
});
it("切换 / 清除 / 计数", () => {
let s = toggleValue({}, "resolution", "2160p");
s = toggleValue(s, "resolution", "1080p");
s = toggleValue(s, "season", "1");
expect(selectedCount(s)).toBe(3);
s = toggleValue(s, "resolution", "2160p");
expect(s.resolution).toEqual(["1080p"]);
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: "releaseType", value: "encode", dimLabel: "版本", label: "蓝光压制" },
{ dim: "season", value: "2", dimLabel: "季", label: "2" },
]);
});
it("本机记忆只保留分辨率/编码/版本/促销/HDR,坏数据忽略", () => {
const sel = { resolution: ["2160p"], site: ["x"], releaseGroup: ["FLUX"], hdr: ["DV"] };
expect(persistable(sel)).toEqual({ resolution: ["2160p"], hdr: ["DV"] });
expect(parseStored(JSON.stringify(sel))).toEqual({ resolution: ["2160p"], hdr: ["DV"] });
expect(parseStored("{bad")).toEqual({});
expect(parseStored(JSON.stringify({ resolution: "2160p", videoCodec: [1, "H.265"] }))).toEqual({
videoCodec: ["H.265"],
});
expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search"));
});
});
@@ -0,0 +1,131 @@
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
import { useCallback, useEffect, useState } from "react";
// 资源快速筛选(SEARCH-3)的状态与参数:搜索结果页和站点资源 Tab 共用。
// 服务端口径见 @umt/domain release-facets:同一维度多选为 OR,不同维度为 AND。
export type QuickDim = keyof ReleaseQuickFacets;
export type QuickSelection = Partial<Record<QuickDim, string[]>>;
/** 筛选条上的一个入口;「季/集」一个入口里分两节 */
export type QuickGroup = { key: string; label: string; dims: { dim: QuickDim; label: string }[] };
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: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] },
{
key: "seasonEpisode",
label: "季/集",
dims: [
{ dim: "season", label: "季" },
{ dim: "episode", label: "集" },
],
},
];
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr"];
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
export function formatQuickFilter(sel: QuickSelection): string | undefined {
const s = DIM_ORDER.filter((d) => sel[d]?.length)
.map((d) => `${d}:${sel[d]!.map((v) => encodeURIComponent(v)).join(",")}`)
.join(";");
return s || undefined;
}
export const selectedCount = (sel: QuickSelection, dims: readonly QuickDim[] = DIM_ORDER) =>
dims.reduce((n, d) => n + (sel[d]?.length ?? 0), 0);
export function toggleValue(sel: QuickSelection, dim: QuickDim, value: string): QuickSelection {
const cur = sel[dim] ?? [];
const next = cur.includes(value) ? cur.filter((v) => v !== value) : [...cur, value];
return { ...sel, [dim]: next };
}
export function clearDims(sel: QuickSelection, dims: readonly QuickDim[]): QuickSelection {
const next = { ...sel };
for (const d of dims) delete next[d];
return next;
}
/** 已选条件的标签(按维度顺序),文字取分面里的选项名 */
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)));
return DIM_ORDER.flatMap((dim) =>
(sel[dim] ?? []).map((value) => ({
dim,
value,
dimLabel: dimLabel.get(dim) ?? dim,
label: facets?.[dim]?.find((o: FacetCount) => o.value === value)?.label ?? value,
})),
);
}
/** 本机记住的选择只保留固定档位的维度 */
export function persistable(sel: QuickSelection): QuickSelection {
const out: QuickSelection = {};
for (const d of PERSISTED) if (sel[d]?.length) out[d] = [...sel[d]!];
return out;
}
export function parseStored(raw: string | null): QuickSelection {
if (!raw) return {};
try {
const v = JSON.parse(raw) as unknown;
if (!v || typeof v !== "object") return {};
const out: QuickSelection = {};
for (const d of PERSISTED) {
const list = (v as Record<string, unknown>)[d];
if (Array.isArray(list)) {
const vals = list.filter((x): x is string => typeof x === "string" && x.length > 0);
if (vals.length) out[d] = vals;
}
}
return out;
} catch {
return {};
}
}
/** localStorage 键:按页面区分(搜索结果页 / 站点资源 Tab) */
export const quickFilterStorageKey = (page: "search" | "site") => `umt.quickFilters.${page}`;
/**
* 快速筛选状态;挂载后从 localStorage 读上次的选择(服务端渲染时没有 localStorage)。
* 读写失败(隐私模式等)只是不记忆,不影响筛选。
*/
export function useQuickFilters(page: "search" | "site"): [QuickSelection, (s: QuickSelection) => void] {
const key = quickFilterStorageKey(page);
const [sel, setSel] = useState<QuickSelection>({});
useEffect(() => {
try {
const stored = parseStored(globalThis.localStorage?.getItem(key) ?? null);
if (selectedCount(stored)) setSel(stored);
} catch {
// 读不到就不恢复
}
}, [key]);
const set = useCallback(
(next: QuickSelection) => {
setSel(next);
try {
globalThis.localStorage?.setItem(key, JSON.stringify(persistable(next)));
} catch {
// 写不了就不记忆
}
},
[key],
);
return [sel, set];
}
@@ -1,4 +1,4 @@
import type { FacetCount, ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import type { ReleaseSearchPage, SearchRelease } from "@umt/contracts";
import {
ArrowDown,
ArrowDownWideNarrow,
@@ -19,17 +19,19 @@ import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch }
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
import { FilterSelect } from "~/components/console/filter-bar.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { ChipToggleGroup } from "~/components/library/parts.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { relTime } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts";
import { QuickFilterBar } from "./quick-filter-bar.tsx";
import { formatQuickFilter, type QuickSelection, selectedCount, useQuickFilters } from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/制作组/季集,多选带计数)+ 大小/免费/H&R、排序、游标;
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
@@ -68,7 +70,12 @@ export function parseWorkRef(ref: string): ReleaseTarget | null {
return null;
}
export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteIds = ""): ReleaseSearchFilters {
export function toApiFilters(
target: ReleaseTarget,
f: ReleaseFilterState,
siteIds = "",
quick: QuickSelection = {},
): ReleaseSearchFilters {
const bool = (v: string) => (v === "true" || v === "false" ? v : undefined);
const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined;
return {
@@ -77,6 +84,7 @@ export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteI
siteFilter: f.sites || undefined,
resolution: f.resolution || undefined,
source: f.source || undefined,
qf: formatQuickFilter(quick),
minSize: gbToBytes(f.minGb),
maxSize: gbToBytes(f.maxGb),
free: bool(f.free),
@@ -86,8 +94,6 @@ export function toApiFilters(target: ReleaseTarget, f: ReleaseFilterState, siteI
};
}
const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []);
export function ReleaseResults({
target,
siteIds,
@@ -104,14 +110,20 @@ export function ReleaseResults({
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
resetFilters: () => void;
}) {
const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]);
const [quick, setQuick] = useQuickFilters("search");
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
const res = useReleaseSearch(api);
const refresh = useRefreshReleaseSearch();
const pages = res.data?.pages ?? [];
const first = pages[0];
const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const [dl, setDl] = useState<SearchRelease | null>(null);
const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const filtered =
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
const resetAll = () => {
setQuick({});
resetFilters();
};
return (
<section aria-labelledby="search-releases" className="min-w-0">
@@ -155,9 +167,8 @@ export function ReleaseResults({
{first ? <CoverageNotice page={first} /> : null}
{first ? (
<Filters page={first} filters={filters} setFilter={setFilter} onReset={filtered ? resetFilters : undefined} />
) : null}
{first ? <QuickFilterBar facets={first.facets} value={quick} onChange={setQuick} /> : null}
{first ? <Filters filters={filters} setFilter={setFilter} onReset={filtered ? resetAll : undefined} /> : null}
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
{res.isPending ? (
@@ -247,16 +258,11 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
);
}
const facetOpts = (items: FacetCount[]) =>
items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` }));
function Filters({
page,
filters,
setFilter,
onReset,
}: {
page: ReleaseSearchPage;
filters: ReleaseFilterState;
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
onReset?: () => void;
@@ -265,30 +271,10 @@ function Filters({
const [maxGb, setMaxGb] = useState(filters.maxGb);
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
const facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [
{ key: "sites", label: "站点", items: page.facets.sites },
{ key: "resolution", label: "分辨率", items: page.facets.resolutions },
{ key: "source", label: "来源", items: page.facets.sources },
];
const desc = filters.order !== "asc";
return (
<div className="mb-3 space-y-2.5">
{facets
.filter((f) => f.items.length > 0)
.map((f) => (
<div key={f.key} className="flex min-w-0 flex-wrap items-start gap-2">
<span className="w-12 shrink-0 pt-1 text-meta text-muted-foreground">{f.label}</span>
<div className="min-w-0 flex-1">
<ChipToggleGroup
label={f.label}
options={facetOpts(f.items)}
value={csvList(filters[f.key])}
onChange={(v) => setFilter(f.key, v.join(","))}
/>
</div>
</div>
))}
<div className="flex flex-wrap items-center gap-2">
<div className="flex items-center gap-1.5">
<span className="text-meta text-muted-foreground">大小</span>
@@ -73,6 +73,11 @@ describe("站点选择", () => {
siteFilter: "b",
});
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",
);
expect(toApiFilters({ q: "x" }, f, "", {}).qf).toBeUndefined();
});
});
+37 -22
View File
@@ -26,6 +26,8 @@ import { FilterBar, FilterSelect } from "~/components/console/filter-bar.tsx";
import { consolePath } from "~/components/console/nav.ts";
import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { QuickFilterBar } from "~/components/search/quick-filter-bar.tsx";
import { formatQuickFilter, selectedCount, useQuickFilters } from "~/components/search/quick-filter-state.ts";
import { SiteAccountsTab } from "~/components/sites/site-accounts-tab.tsx";
import { SiteMoreMenu } from "~/components/sites/site-card.tsx";
import { type SiteDialog, SiteDialogHost, useSiteChecks } from "~/components/sites/site-dialogs.tsx";
@@ -40,13 +42,6 @@ import { ReleaseRow } from "~/components/workbench/release-list.tsx";
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
import { errorMessage } from "~/lib/api.ts";
const PROMOS = [
{ value: "free", label: "免费" },
{ value: "2xfree", label: "2x 免费" },
{ value: "50%", label: "50%" },
{ value: "30%", label: "30%" },
{ value: "2x", label: "2x 上传" },
];
const RESOLUTION: { value: ResolutionStatus; label: string }[] = [
{ value: "resolved", label: "已识别" },
{ value: "unresolved", label: "待识别" },
@@ -120,26 +115,34 @@ export default function SiteWorkbenchPage() {
const [qInput, setQInput] = useState("");
const q = useDebounced(qInput.trim());
const [category, setCategory] = useState("");
const [promotion, setPromotion] = useState("");
const [resolution, setResolution] = useState("");
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
const [live, setLive] = useState(false);
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
const [quick, setQuick] = useQuickFilters("site");
const qf = formatQuickFilter(quick);
// 实时补查只对开启时的快速筛选生效;改快速筛选不会再次请求站点
const [liveAt, setLiveAt] = useState<string | null>(null);
const live = liveAt !== null && liveAt === (qf ?? "");
const setLive = (v: boolean) => setLiveAt(v ? (qf ?? "") : null);
const [openId, setOpenId] = useState<string | null>(null);
const filters: ReleaseFilters = {
q: q || undefined,
category: category || undefined,
promotion: promotion || undefined,
resolution: (resolution || undefined) as ResolutionStatus | undefined,
qf,
};
const releases = useSiteReleases(tab === "resources" ? siteId : "", filters, live);
// 实时补查只对当前搜索生效一次
// biome-ignore lint/correctness/useExhaustiveDependencies: 条件变化时回到快照优先
useEffect(() => setLive(false), [q, category, promotion, resolution]);
useEffect(() => setLiveAt(null), [q, category, resolution]);
const pages = releases.data?.pages ?? [];
const all = useMemo(() => pages.flatMap((p) => p.items), [pages]);
const coverage = pages[0]?.coverage;
const quickTotal = pages[0]?.total;
/** 本地有资源,但快速筛选后一条都没有 */
const quickHidden = selectedCount(quick) > 0 && (pages[0]?.unfilteredTotal ?? 0) > 0 && quickTotal === 0;
const now = Date.now();
const rows = all.filter((r) => localMatch(r, local, now));
const localActive = Object.values(local).some(Boolean);
@@ -162,11 +165,11 @@ export default function SiteWorkbenchPage() {
const reset = () => {
setQInput("");
setCategory("");
setPromotion("");
setQuick({});
setResolution("");
setLocal({ hr: "", seeding: "", size: "", age: "" });
};
const anyFilter = !!(qInput || category || promotion || resolution || localActive);
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
if (sites.error && !sites.data) return <ErrorState error={sites.error} onRetry={() => void sites.refetch()} />;
if (sites.data && !s)
@@ -327,7 +330,6 @@ export default function SiteWorkbenchPage() {
}
>
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
<FilterSelect label="促销" value={promotion} onChange={setPromotion} options={PROMOS} />
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
<FilterSelect
label="H&R"
@@ -361,6 +363,8 @@ export default function SiteWorkbenchPage() {
/>
</FilterBar>
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} />
{coverage || refreshFailed ? (
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
) : null}
@@ -373,25 +377,36 @@ export default function SiteWorkbenchPage() {
) : rows.length === 0 ? (
<EmptyState
plain
icon={all.length ? SearchX : Globe}
icon={all.length || quickHidden ? SearchX : Globe}
title={
all.length ? "已加载的结果里没有符合筛选的资源" : q ? "本地快照里没有匹配的资源" : "还没有快照数据"
all.length
? "已加载的结果里没有符合筛选的资源"
: quickHidden
? "没有符合筛选条件的资源"
: q
? "本地快照里没有匹配的资源"
: "还没有快照数据"
}
description={
all.length
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
: quickHidden
? "减少快速筛选条件再看。"
: coverage?.liveError
? `实时补查失败(${coverage.liveError}),站点上可能仍有该资源。`
: q
? "点「实时补查」直接在站点搜索。"
: "点「快照」抓取站点列表。"
}
/>
) : (
<>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 border-b border-border/60 px-4 py-2 text-meta text-muted-foreground">
<span>
已加载 {all.length} 条{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
{quickTotal !== undefined && selectedCount(quick)
? `共 ${quickTotal} 条,已加载 ${all.length} 条`
: `已加载 ${all.length} 条`}
{localActive ? `,本地筛选后 ${rows.length} 条` : ""}
</span>
{freeCount ? <span>其中免费 {freeCount}</span> : null}
{reqStats.data ? (