feat(web): merge search filters into one row and tidy episode versions (POLISH-1)

- H&R becomes a quick-filter facet (yes/no/unknown, with counts) on search
  results and the site resources tab; drop the old free/H&R selects, keep
  size range and sort on the same row (mobile: drawer), single clear button
- legacy free/hr URL params migrate into quick filters; stored selections
  keep the same localStorage key
- search timing and query terms move into TechInfo
- library episode versions: click the whole row to expand, versions listed
  as sibling rows, details only under the selected version, raw values in
  TechInfo
- slot comparison gaps get reason codes (matchGapText in contracts
  reasons.ts): "可换成 2160p", "缺 HDR 版本"

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-29 22:06:32 +08:00
1 parent 12bc4664b6
commit 71b129d85a
19 files changed
+668 -290

No files matched your search

@@ -175,7 +175,9 @@ describe.skipIf(!hasPg)("LIBV-1 入库版本", () => {
const [e1, e2, e3] = s1.episodes;
expect(e1.versions.map((x: { assetId: string }) => x.assetId).sort()).toEqual([ids.e1dv, ids.e1bd].sort());
expect(e1.matches).toEqual([{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [] }]);
expect(e1.matches).toEqual([
{ slotId: v.targets[0].slotId, state: "satisfied", assetId: ids.e1dv, reasons: [], codes: [] },
]);
const dv = e1.versions.find((x: { assetId: string }) => x.assetId === ids.e1dv);
expect(dv).toMatchObject({
resolution: "2160p",
@@ -355,6 +355,7 @@ export const searchFacetInput = (r: BaseRelease): FacetInput => ({
uploadFactor: r.promotion.uploadFactor,
expiresAt: r.promotion.expiresAt,
},
hr: r.hr,
});
/** 快速筛选(SEARCH-3):先过旧筛选参数,再按 qf 多选筛选并统计分面 */
@@ -109,6 +109,8 @@ interface LightRow {
parsed: unknown;
promotion: unknown;
promotion_expires_at: Date | null;
/** raw._site.hr(列表快照解析的 H&R);没有为 null */
hr: unknown;
}
const num = (v: unknown): number | null => (typeof v === "number" && Number.isFinite(v) ? v : null);
@@ -140,6 +142,7 @@ export function lightFacetInput(r: LightRow, site: Pick<SiteRow, "id" | "name">)
uploadFactor: num(raw.uploadFactor),
expiresAt: expires && !Number.isNaN(expires.getTime()) ? expires.toISOString() : null,
},
hr: r.hr === true || r.hr === false ? r.hr : "unknown",
};
}
@@ -180,6 +183,7 @@ export async function listSiteReleases(
sql<unknown>`r.parsed`.as("parsed"),
sql<unknown>`r.promotion`.as("promotion"),
"r.promotion_expires_at",
sql<unknown>`r.raw->'_site'->'hr'`.as("hr"),
])
.where("r.site_id", "=", site.id);
if (q.q) {
+13 -1
View File
@@ -17,7 +17,7 @@ import { EventBus } from "../../src/http/sse.ts";
import { createAcquisitionOrganizeModule } from "../../src/modules/acquisition-organize/index.ts";
import { createAgentModule } from "../../src/modules/agent/index.ts";
import { createSearchModule } from "../../src/modules/search/index.ts";
import { type BaseRelease, filterReleases } from "../../src/modules/search/releases.ts";
import { type BaseRelease, filterReleases, quickFilter } from "../../src/modules/search/releases.ts";
import { createSourcesIdentityModule } from "../../src/modules/sources-identity/index.ts";
import type { ServerContext } from "../../src/modules/types.ts";
import { DEV_KEY, memoryLogger, memoryStore } from "../helpers.ts";
@@ -412,6 +412,7 @@ describe.skipIf(!up)("全局搜索", () => {
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.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(3);
// 旧分面仍在
expect(d.facets.resolutions.length).toBe(2);
@@ -461,6 +462,8 @@ describe.skipIf(!up)("全局搜索", () => {
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);
// H&R 分面来自资源表 raw._site.hr,每条资源恰好落在一档
expect(d.facets.hr.reduce((n: number, f: Loose) => n + f.count, 0)).toBe(2);
d = await list("resolution:1080p");
expect(d.items.map((i: Loose) => i.siteReleaseId)).toEqual(["900001"]);
expect(d).toMatchObject({ total: 1, unfilteredTotal: 2 });
@@ -487,6 +490,15 @@ describe.skipIf(!up)("全局搜索", () => {
filterReleases(items, { q: "x", hr, sort: "default", order: "desc", limit: 50 }).map((i) => i.siteReleaseId);
expect(f("true")).toEqual(["a"]);
expect(f("false")).toEqual(["b", "c"]);
// 快速筛选里的 H&R 分面(POLISH-1):有 / 无 / 未知,多选为 OR
const qfq = (qf: string) => quickFilter(items, { q: "x", qf, sort: "default", order: "desc", limit: 50 }, 0);
const all = qfq("");
expect(all.facets.hr).toEqual([
{ value: "yes", label: "有", count: 1 },
{ value: "no", label: "无", count: 1 },
{ value: "unknown", label: "未知", count: 1 },
]);
expect(qfq("hr:no,unknown").items.map((i) => i.siteReleaseId)).toEqual(["b", "c"]);
});
it("页面与操作跳转;scope=all 只查作品与操作(不查站点)", async () => {