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:
commit
4c7375b52f
20 files changed
+1273
-81
No files matched your search
@@ -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 = 做种数降序,同分发布时间新→旧 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")];
|
||||
|
||||
@@ -14,6 +14,8 @@ export type ReleaseFilters = {
|
||||
category?: string;
|
||||
promotion?: string;
|
||||
resolution?: ResolutionStatus;
|
||||
/** 快速筛选(SEARCH-3):`维度:值,值;维度:值`;只筛本地数据 */
|
||||
qf?: string;
|
||||
};
|
||||
|
||||
export const releaseKeys = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
Reference in new issue
Block a user