Merge branch 'polish1'
Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
commit
7bbe0d4de7
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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { AssetVersion, SeasonVersions, UnitMatch, VersionSummary } from "@umt/contracts";
|
||||
import {
|
||||
type AssetVersion,
|
||||
matchGapTexts,
|
||||
type SeasonVersions,
|
||||
type UnitMatch,
|
||||
type VersionSummary,
|
||||
} from "@umt/contracts";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { useLibraryVersions } from "~/api/library-versions.ts";
|
||||
import { type KvItem, KvList } from "~/components/console/kv-list.tsx";
|
||||
import { consolePath } from "~/components/console/nav.ts";
|
||||
import { TechInfo } from "~/components/console/tech-info.tsx";
|
||||
import { Panel } from "~/components/library/parts.tsx";
|
||||
import { cn, formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
// LIBV-1 入库版本:每集一行规格(分辨率 · 来源 · 编码 · HDR · 制作组 · 大小),多个版本可展开逐行;
|
||||
// 季头一行汇总;路径、音轨、字幕、探测原始值放进「技术信息」折叠。
|
||||
// LIBV-1 入库版本:每集一行规格(分辨率 · 来源 · 编码 · HDR · 制作组 · 大小);季头一行汇总。
|
||||
// POLISH-1:点整行展开,多个版本同级并列,选中的版本下方才显示路径、音轨、字幕、时长;
|
||||
// 规格来源、探测原始值、比对原文放进 TechInfo;与收录版本的差距用 matchGapTexts 转成人话。
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
REMUX: "Remux",
|
||||
@@ -85,9 +93,15 @@ function duration(sec: number | null): string | null {
|
||||
|
||||
const SPEC_FROM: Record<string, string> = { name: "文件名", probe: "ffprobe" };
|
||||
|
||||
/** 技术信息:路径、音轨、字幕、入库时间、下载任务、探测原始值 */
|
||||
// TODO(DEAI-2):TechInfo 组件合入后改用 components/console/tech-info.tsx,和其他页面统一折叠样式与默认展开偏好
|
||||
function VersionTech({ v }: { v: AssetVersion }) {
|
||||
/** 与收录版本的差距 → 人话短句("可换成 2160p"、"缺 HDR 版本");原文留给技术信息 */
|
||||
export function gapLine(match: UnitMatch | undefined): string | null {
|
||||
if (match?.state !== "upgradeable") return null;
|
||||
const texts = matchGapTexts(match.codes);
|
||||
return texts.length ? texts.join(" · ") : "还有更好的版本可换";
|
||||
}
|
||||
|
||||
/** 选中版本的详情:路径、音轨、字幕、时长;规格来源、探测原始值、比对原文进「技术信息」 */
|
||||
function VersionDetail({ v, match }: { v: AssetVersion; match?: UnitMatch | undefined }) {
|
||||
const items: KvItem[] = [
|
||||
{ label: "文件", value: v.absolutePath ?? v.relativePath ?? v.fileName, mono: true, wide: true },
|
||||
{ label: "音轨", value: audioText(v) },
|
||||
@@ -103,58 +117,113 @@ function VersionTech({ v }: { v: AssetVersion }) {
|
||||
</Link>
|
||||
) : null,
|
||||
},
|
||||
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null },
|
||||
].filter((it) => it.value !== null && it.value !== undefined && it.value !== "");
|
||||
const ownMatch = match && match.assetId === v.assetId ? match : undefined;
|
||||
return (
|
||||
<details className="group mt-1">
|
||||
<summary className="inline-flex cursor-pointer list-none items-center gap-1 text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
技术信息
|
||||
<ChevronDown className="size-3.5 transition-transform duration-micro group-open:rotate-180" aria-hidden />
|
||||
</summary>
|
||||
<div className="grid gap-3 pt-2 pb-1">
|
||||
<KvList items={items} />
|
||||
<div className="grid gap-2 pt-1 pb-2 pl-2">
|
||||
<KvList items={items} />
|
||||
<TechInfo
|
||||
items={[
|
||||
{ label: "文件 ID", value: v.assetId, wide: true },
|
||||
{ label: "规格来源", value: v.specFrom.map((f) => SPEC_FROM[f] ?? f).join(" + ") || null },
|
||||
{ label: "比对说明", value: ownMatch?.reasons.join(";") || null, wide: true },
|
||||
{ label: "原因码", value: ownMatch?.codes.join(", ") || null, wide: true },
|
||||
]}
|
||||
>
|
||||
{v.probe ? (
|
||||
<details className="group/raw">
|
||||
<summary className="cursor-pointer list-none text-meta text-muted-foreground hover:text-foreground [&::-webkit-details-marker]:hidden">
|
||||
探测原始值
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-64 overflow-auto rounded-lg bg-muted/40 p-2 font-mono text-caption break-all whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(v.probe, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
<pre className="max-h-64 overflow-auto rounded-lg bg-muted/40 p-2 font-mono text-caption break-all whitespace-pre-wrap text-foreground">
|
||||
{JSON.stringify(v.probe, null, 2)}
|
||||
</pre>
|
||||
) : null}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个版本:规格一行 + 技术信息折叠 */
|
||||
export function VersionRow({ v, best, aside }: { v: AssetVersion; best?: boolean; aside?: ReactNode }) {
|
||||
const parts = versionParts(v);
|
||||
const missing = v.status === "missing" || (v.locationStatus !== null && v.locationStatus !== "available");
|
||||
return (
|
||||
<div className="min-w-0">
|
||||
<p className="flex min-w-0 flex-wrap items-baseline gap-x-1.5 text-caption tabular-nums text-muted-foreground">
|
||||
{parts.length ? (
|
||||
parts.map((p, i) => (
|
||||
<span key={`${i}-${p}`} className={cn("whitespace-nowrap", i === 0 && "font-medium text-foreground")}>
|
||||
{i > 0 ? <span className="mr-1.5 text-muted-foreground/60">·</span> : null}
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="break-all">{v.fileName}</span>
|
||||
)}
|
||||
{best ? <span className="text-meta text-success">符合收录版本</span> : null}
|
||||
{missing ? <span className="text-meta text-warning">文件不在原位置</span> : null}
|
||||
{aside}
|
||||
</p>
|
||||
<VersionTech v={v} />
|
||||
</TechInfo>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一集的版本:一个时直接一行;多个时先显示达标(或第一个)版本,「N 个版本」展开逐行 */
|
||||
/** 一个版本的规格一行:2160p · WEB-DL · HEVC · DV · FLUX · 13.2 GB + 状态 */
|
||||
export function VersionSpecLine({ v, best, aside }: { v: AssetVersion; best?: boolean; aside?: ReactNode }) {
|
||||
const parts = versionParts(v);
|
||||
const missing = v.status === "missing" || (v.locationStatus !== null && v.locationStatus !== "available");
|
||||
return (
|
||||
<span className="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-1.5 text-caption tabular-nums text-muted-foreground">
|
||||
{parts.length ? (
|
||||
parts.map((p, i) => (
|
||||
<span key={`${i}-${p}`} className={cn("whitespace-nowrap", i === 0 && "font-medium text-foreground")}>
|
||||
{i > 0 ? <span className="mr-1.5 text-muted-foreground/60">·</span> : null}
|
||||
{p}
|
||||
</span>
|
||||
))
|
||||
) : (
|
||||
<span className="break-all">{v.fileName}</span>
|
||||
)}
|
||||
{best ? <span className="text-meta text-success">符合收录版本</span> : null}
|
||||
{missing ? <span className="text-meta text-warning">文件不在原位置</span> : null}
|
||||
{aside}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const ROW_CLASS =
|
||||
"flex w-full min-w-0 cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left transition-colors duration-micro hover:bg-foreground/[0.05] focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none";
|
||||
|
||||
/**
|
||||
* 版本列表(POLISH-1):每个版本一行同级并列,点一行选中并在它下方展开详情,再点收起。
|
||||
* 电影面板、没对上集数的文件都用它;剧集每集先收成一行,见 EpisodeVersionList。
|
||||
*/
|
||||
export function VersionList({
|
||||
versions,
|
||||
match,
|
||||
initial,
|
||||
onCollapse,
|
||||
}: {
|
||||
versions: AssetVersion[];
|
||||
match?: UnitMatch | undefined;
|
||||
/** 初始选中的版本;缺省不选 */
|
||||
initial?: string | null;
|
||||
/** 收起选中版本时回调(剧集用来收回到一行) */
|
||||
onCollapse?: () => void;
|
||||
}) {
|
||||
const [selected, setSelected] = useState<string | null>(initial ?? null);
|
||||
const tagBest = match?.state === "satisfied" && versions.length > 1;
|
||||
return (
|
||||
<ul className="space-y-0.5">
|
||||
{versions.map((v) => {
|
||||
const on = selected === v.assetId;
|
||||
return (
|
||||
<li key={v.assetId} className={cn("rounded-lg", on && "bg-foreground/[0.04]")}>
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={on}
|
||||
data-version-row
|
||||
onClick={() => {
|
||||
if (!on) setSelected(v.assetId);
|
||||
else if (onCollapse) onCollapse();
|
||||
else setSelected(null);
|
||||
}}
|
||||
className={ROW_CLASS}
|
||||
>
|
||||
<VersionSpecLine v={v} best={tagBest && v.assetId === match?.assetId} />
|
||||
<ChevronDown
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-3.5 shrink-0 text-muted-foreground transition-transform duration-micro",
|
||||
on && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
{on ? <VersionDetail v={v} match={match} /> : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一集的版本:收起时一行(达标或最接近的版本 + "N 个版本"),点整行展开;
|
||||
* 展开后每个版本一行同级并列,选中的版本下方显示文件路径、音轨、字幕等。
|
||||
* 与收录版本的差距用人话写在下面("可换成 2160p · 缺 HDR 版本")。
|
||||
*/
|
||||
export function EpisodeVersionList({
|
||||
versions,
|
||||
match,
|
||||
@@ -167,46 +236,32 @@ export function EpisodeVersionList({
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!versions.length) return null;
|
||||
const lead = versions.find((v) => v.assetId === match?.assetId) ?? versions[0]!;
|
||||
const upgrade = match?.state === "upgradeable" ? match : null;
|
||||
const note = upgrade ? (
|
||||
<p className="text-meta text-warning">
|
||||
可升级
|
||||
{upgrade.reasons.length ? <span className="text-muted-foreground">:{upgrade.reasons.join(";")}</span> : null}
|
||||
</p>
|
||||
) : null;
|
||||
if (versions.length === 1)
|
||||
return (
|
||||
<div className={cn("mt-1.5 space-y-0.5", className)}>
|
||||
<VersionRow v={lead} />
|
||||
{note}
|
||||
</div>
|
||||
);
|
||||
const rest = versions.filter((v) => v !== lead);
|
||||
const tagBest = match?.state === "satisfied";
|
||||
const toggle = (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
onClick={() => setOpen(!open)}
|
||||
className="ml-1 inline-flex cursor-pointer items-center gap-0.5 rounded-md text-meta text-muted-foreground hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||
>
|
||||
{versions.length} 个版本
|
||||
<ChevronDown className={cn("size-3.5 transition-transform duration-micro", open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
);
|
||||
const ordered = [lead, ...versions.filter((v) => v !== lead)];
|
||||
const gap = gapLine(match);
|
||||
return (
|
||||
<div className={cn("mt-1.5 space-y-1", className)}>
|
||||
<VersionRow v={lead} best={open && tagBest} aside={toggle} />
|
||||
{note}
|
||||
<div className={cn("-mx-2 mt-1 space-y-0.5", className)}>
|
||||
{open ? (
|
||||
<ul className="space-y-2 border-l border-border/70 pl-3">
|
||||
{rest.map((v) => (
|
||||
<li key={v.assetId}>
|
||||
<VersionRow v={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
<VersionList versions={ordered} match={match} initial={lead.assetId} onCollapse={() => setOpen(false)} />
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={false}
|
||||
data-version-row
|
||||
onClick={() => setOpen(true)}
|
||||
className={ROW_CLASS}
|
||||
>
|
||||
<VersionSpecLine
|
||||
v={lead}
|
||||
aside={
|
||||
versions.length > 1 ? (
|
||||
<span className="text-meta whitespace-nowrap text-muted-foreground">{versions.length} 个版本</span>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<ChevronDown aria-hidden className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
)}
|
||||
{gap ? <p className="px-2 text-meta text-warning">{gap}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -221,25 +276,12 @@ export function MovieVersionsPanel({ entityId }: { entityId: string }) {
|
||||
const versions = [...data.movie.versions].sort(
|
||||
(a, b) => Number(b.assetId === match?.assetId) - Number(a.assetId === match?.assetId),
|
||||
);
|
||||
const state = match
|
||||
? match.state === "satisfied"
|
||||
? `符合「${target?.name}」`
|
||||
: match.state === "upgradeable"
|
||||
? `可升级${match.reasons.length ? `:${match.reasons.join(";")}` : ""}`
|
||||
: null
|
||||
: null;
|
||||
const state = match?.state === "satisfied" ? `符合「${target?.name}」` : gapLine(match);
|
||||
return (
|
||||
<Panel title="入库版本" description={[summaryText(data.summary), state].filter(Boolean).join(" · ")}>
|
||||
<ul className="divide-y divide-border/60">
|
||||
{versions.map((v) => (
|
||||
<li key={v.assetId} className="py-2.5 first:pt-0 last:pb-0">
|
||||
<VersionRow
|
||||
v={v}
|
||||
best={versions.length > 1 && match?.state === "satisfied" && v.assetId === match.assetId}
|
||||
/>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="-mx-2">
|
||||
<VersionList versions={versions} match={match} />
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -260,13 +302,9 @@ export function UnplacedVersions({ versions }: { versions: AssetVersion[] }) {
|
||||
<ChevronDown className={cn("size-4 shrink-0 text-muted-foreground", open && "rotate-180")} aria-hidden />
|
||||
</button>
|
||||
{open ? (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{versions.map((v) => (
|
||||
<li key={v.assetId}>
|
||||
<VersionRow v={v} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="-mx-2 mt-2">
|
||||
<VersionList versions={versions} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FacetCount, ReleaseQuickFacets } from "@umt/contracts";
|
||||
import { Check, ChevronDown, X } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Check, ChevronDown, SlidersHorizontal, X } from "lucide-react";
|
||||
import { type ReactNode, 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";
|
||||
@@ -18,8 +18,12 @@ import {
|
||||
} from "./quick-filter-state.ts";
|
||||
|
||||
// 资源快速筛选条(SEARCH-3,对照 MP 搜索结果筛选):一行可横向滚动的维度按钮,点开多选;
|
||||
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,可「清除全部」。
|
||||
// 选项带计数(按其它维度的已选条件),0 计数置灰;已选条件以可移除标签列出,只有一个「清除」。
|
||||
// 桌面用弹层,手机用底部抽屉。搜索结果页与站点资源 Tab 共用。
|
||||
// POLISH-1:H&R 是分面维度;页面可在同一行右侧放大小 / 排序(aside),手机上收进「大小与排序」抽屉。
|
||||
|
||||
/** 分面之外的已选条件(如大小区间),和分面标签排在一起 */
|
||||
export type ExtraTag = { key: string; dimLabel: string; label: string; onRemove: () => void };
|
||||
|
||||
export function QuickFilterBar({
|
||||
facets,
|
||||
@@ -27,6 +31,10 @@ export function QuickFilterBar({
|
||||
onChange,
|
||||
hide = [],
|
||||
className,
|
||||
aside,
|
||||
extraTags = [],
|
||||
onClearAll,
|
||||
hideClear = false,
|
||||
}: {
|
||||
facets: ReleaseQuickFacets | undefined;
|
||||
value: QuickSelection;
|
||||
@@ -34,9 +42,17 @@ export function QuickFilterBar({
|
||||
/** 不显示的维度(站点资源 Tab 不显示站点) */
|
||||
hide?: QuickDim[];
|
||||
className?: string;
|
||||
/** 同一行右侧的控件(桌面);手机上收进一个抽屉,label 是入口名,active 时入口高亮 */
|
||||
aside?: { content: (layout: "inline" | "sheet") => ReactNode; label: string; active: boolean };
|
||||
extraTags?: ExtraTag[];
|
||||
/** 「清除」同时清掉页面自己的条件;缺省只清分面 */
|
||||
onClearAll?: () => void;
|
||||
/** 页面已有自己的「清除筛选」时不再显示 */
|
||||
hideClear?: boolean;
|
||||
}) {
|
||||
const mobile = useIsMobile();
|
||||
const [sheet, setSheet] = useState<QuickGroup | null>(null);
|
||||
const [asideOpen, setAsideOpen] = useState(false);
|
||||
const groups = QUICK_GROUPS.filter((g) => {
|
||||
const dims = g.dims.filter((d) => !hide.includes(d.dim));
|
||||
if (!dims.length) return false;
|
||||
@@ -52,74 +68,110 @@ export function QuickFilterBar({
|
||||
);
|
||||
});
|
||||
const tags = selectedTags(value, facets).filter((t) => !hide.includes(t.dim));
|
||||
if (!groups.length && !tags.length) return null;
|
||||
if (!groups.length && !tags.length && !extraTags.length && !aside) return null;
|
||||
const clearAll = onClearAll ?? (() => onChange({}));
|
||||
|
||||
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 = (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="快速筛选"
|
||||
className="-mx-1 flex min-w-0 flex-1 gap-1.5 overflow-x-auto px-1 pb-0.5 [scrollbar-width:none]"
|
||||
>
|
||||
{/* 手机:大小与排序放在最前,免得滚到最后才看到 */}
|
||||
{aside && mobile ? (
|
||||
<button
|
||||
key={g.key}
|
||||
type="button"
|
||||
aria-haspopup="dialog"
|
||||
onClick={mobile ? () => setSheet(g) : undefined}
|
||||
onClick={() => setAsideOpen(true)}
|
||||
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
|
||||
aside.active
|
||||
? "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" />
|
||||
<SlidersHorizontal className="h-3.5 w-3.5" />
|
||||
{aside.label}
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
) : null}
|
||||
{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>
|
||||
{aside && !mobile ? <div className="flex shrink-0 items-center gap-1.5">{aside.content("inline")}</div> : null}
|
||||
</div>
|
||||
|
||||
{tags.length ? (
|
||||
{tags.length || extraTags.length ? (
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
||||
{tags.map((t) => (
|
||||
<button
|
||||
<RemovableTag
|
||||
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>
|
||||
dimLabel={t.dimLabel}
|
||||
label={t.label}
|
||||
onRemove={() => onChange(toggleValue(value, t.dim, t.value))}
|
||||
/>
|
||||
))}
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={() => onChange({})}>
|
||||
清除全部
|
||||
</Button>
|
||||
{extraTags.map((t) => (
|
||||
<RemovableTag key={t.key} dimLabel={t.dimLabel} label={t.label} onRemove={t.onRemove} />
|
||||
))}
|
||||
{hideClear ? null : (
|
||||
<Button variant="ghost" size="sm" className="h-7" onClick={clearAll}>
|
||||
清除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{aside && mobile ? (
|
||||
<Sheet open={asideOpen} onOpenChange={setAsideOpen}>
|
||||
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
|
||||
<SheetHeader className="pb-2">
|
||||
<SheetTitle>{aside.label}</SheetTitle>
|
||||
<SheetDescription className="sr-only">{aside.label}</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 space-y-4 overflow-y-auto px-4 pb-4">{aside.content("sheet")}</div>
|
||||
<SheetFooter className="border-t border-border/60">
|
||||
<Button onClick={() => setAsideOpen(false)}>完成</Button>
|
||||
</SheetFooter>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
) : null}
|
||||
|
||||
{mobile ? (
|
||||
<Sheet open={!!sheet} onOpenChange={(o) => !o && setSheet(null)}>
|
||||
<SheetContent side="bottom" className="max-h-[80dvh] gap-0 rounded-t-2xl">
|
||||
@@ -166,6 +218,21 @@ export function QuickFilterBar({
|
||||
);
|
||||
}
|
||||
|
||||
function RemovableTag({ dimLabel, label, onRemove }: { dimLabel: string; label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`移除筛选 ${dimLabel} ${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">{dimLabel}</span>
|
||||
<span className="max-w-40 truncate">{label}</span>
|
||||
<X className="h-3 w-3 text-muted-foreground" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 一个入口的多选列表:每项带计数;0 计数且未选时置灰不可选 */
|
||||
function GroupPanel({
|
||||
group,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
clearDims,
|
||||
formatQuickFilter,
|
||||
mergeLegacyFilters,
|
||||
parseStored,
|
||||
persistable,
|
||||
quickFilterStorageKey,
|
||||
@@ -19,6 +20,7 @@ const empty = {
|
||||
site: [],
|
||||
promotion: [],
|
||||
hdr: [],
|
||||
hr: [],
|
||||
releaseGroup: [],
|
||||
season: [],
|
||||
episode: [],
|
||||
@@ -60,4 +62,20 @@ describe("快速筛选状态", () => {
|
||||
});
|
||||
expect(quickFilterStorageKey("site")).not.toBe(quickFilterStorageKey("search"));
|
||||
});
|
||||
|
||||
it("H&R 分面也记忆;旧版存的内容(没有 hr)照常读出,键不变", () => {
|
||||
expect(persistable({ hr: ["no"], site: ["x"] })).toEqual({ hr: ["no"] });
|
||||
expect(quickFilterStorageKey("search")).toBe("umt.quickFilters.search");
|
||||
const old = JSON.stringify({ resolution: ["2160p"], promotion: ["free"] });
|
||||
expect(parseStored(old)).toEqual({ resolution: ["2160p"], promotion: ["free"] });
|
||||
});
|
||||
|
||||
it("旧 URL 参数 free / hr 并进快速筛选", () => {
|
||||
expect(mergeLegacyFilters({ promotion: ["2x"] }, { free: "true", hr: "true" })).toEqual({
|
||||
promotion: ["2x", "free"],
|
||||
hr: ["yes"],
|
||||
});
|
||||
expect(mergeLegacyFilters({}, { hr: "false" })).toEqual({ hr: ["no", "unknown"] });
|
||||
expect(mergeLegacyFilters({}, { free: "false" })).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -17,6 +17,7 @@ export const QUICK_GROUPS: QuickGroup[] = [
|
||||
{ key: "site", label: "站点", dims: [{ dim: "site", label: "站点" }] },
|
||||
{ key: "promotion", label: "促销", dims: [{ dim: "promotion", label: "促销" }] },
|
||||
{ key: "hdr", label: "HDR", dims: [{ dim: "hdr", label: "HDR" }] },
|
||||
{ key: "hr", label: "H&R", dims: [{ dim: "hr", label: "H&R" }] },
|
||||
{ key: "releaseGroup", label: "制作组", dims: [{ dim: "releaseGroup", label: "制作组" }] },
|
||||
{
|
||||
key: "seasonEpisode",
|
||||
@@ -31,7 +32,7 @@ export const QUICK_GROUPS: QuickGroup[] = [
|
||||
const DIM_ORDER: QuickDim[] = QUICK_GROUPS.flatMap((g) => g.dims.map((d) => d.dim));
|
||||
|
||||
/** 记住上次选择的维度:与具体结果无关的档位;站点、制作组、季集随搜索变化,不记 */
|
||||
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr"];
|
||||
const PERSISTED: QuickDim[] = ["resolution", "videoCodec", "releaseType", "promotion", "hdr", "hr"];
|
||||
|
||||
/** 查询参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent */
|
||||
export function formatQuickFilter(sel: QuickSelection): string | undefined {
|
||||
@@ -98,6 +99,23 @@ export function parseStored(raw: string | null): QuickSelection {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 旧版筛选行(免费 / H&R 下拉,URL 参数 free、hr)并进快速筛选(POLISH-1):
|
||||
* free=true → 促销「免费」;hr=true → H&R「有」;hr=false → H&R「无 / 未知」(旧口径只排除明确 H&R)。
|
||||
* free=false 没有对应的分面组合,丢弃。
|
||||
*/
|
||||
export function mergeLegacyFilters(sel: QuickSelection, legacy: { free?: string; hr?: string }): QuickSelection {
|
||||
const add = (s: QuickSelection, dim: QuickDim, vals: string[]): QuickSelection => ({
|
||||
...s,
|
||||
[dim]: [...new Set([...(s[dim] ?? []), ...vals])],
|
||||
});
|
||||
let next = sel;
|
||||
if (legacy.free === "true") next = add(next, "promotion", ["free"]);
|
||||
if (legacy.hr === "true") next = add(next, "hr", ["yes"]);
|
||||
if (legacy.hr === "false") next = add(next, "hr", ["no", "unknown"]);
|
||||
return next;
|
||||
}
|
||||
|
||||
/** localStorage 键:按页面区分(搜索结果页 / 站点资源 Tab) */
|
||||
export const quickFilterStorageKey = (page: "search" | "site") => `umt.quickFilters.${page}`;
|
||||
|
||||
|
||||
@@ -17,8 +17,8 @@ import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts";
|
||||
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 { TechInfo } from "~/components/console/tech-info.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";
|
||||
@@ -26,13 +26,19 @@ 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 {
|
||||
formatQuickFilter,
|
||||
mergeLegacyFilters,
|
||||
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 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
|
||||
// 促销/HDR/制作组/季集,多选带计数)+ 大小/免费/H&R、排序、游标;
|
||||
// 行操作同站点工作台(库存叠加、下载、委托 Agent)。
|
||||
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
|
||||
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
|
||||
|
||||
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
|
||||
export type ReleaseFilterState = {
|
||||
@@ -76,7 +82,6 @@ export function toApiFilters(
|
||||
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 {
|
||||
...target,
|
||||
@@ -87,8 +92,7 @@ export function toApiFilters(
|
||||
qf: formatQuickFilter(quick),
|
||||
minSize: gbToBytes(f.minGb),
|
||||
maxSize: gbToBytes(f.maxGb),
|
||||
free: bool(f.free),
|
||||
hr: bool(f.hr),
|
||||
// free / hr 是旧筛选行的 URL 参数,进页面时并进快速筛选(mergeLegacyFilters),不再单独发
|
||||
sort,
|
||||
order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined,
|
||||
};
|
||||
@@ -99,7 +103,6 @@ export function ReleaseResults({
|
||||
siteIds,
|
||||
filters,
|
||||
setFilter,
|
||||
resetFilters,
|
||||
onChangeSites,
|
||||
}: {
|
||||
target: ReleaseTarget;
|
||||
@@ -108,7 +111,6 @@ export function ReleaseResults({
|
||||
onChangeSites?: () => void;
|
||||
filters: ReleaseFilterState;
|
||||
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
|
||||
resetFilters: () => void;
|
||||
}) {
|
||||
const [quick, setQuick] = useQuickFilters("search");
|
||||
const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]);
|
||||
@@ -120,10 +122,20 @@ export function ReleaseResults({
|
||||
const [dl, setDl] = useState<SearchRelease | null>(null);
|
||||
const filtered =
|
||||
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
|
||||
const resetAll = () => {
|
||||
// 旧链接里的免费 / H&R 参数并进快速筛选
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在旧参数出现时迁移一次
|
||||
useEffect(() => {
|
||||
if (!filters.free && !filters.hr) return;
|
||||
setQuick(mergeLegacyFilters(quick, { free: filters.free, hr: filters.hr }));
|
||||
setFilter("free", "");
|
||||
setFilter("hr", "");
|
||||
}, [filters.free, filters.hr]);
|
||||
/** 唯一的「清除」:分面、大小区间都清掉,排序保留 */
|
||||
const clearAll = () => {
|
||||
setQuick({});
|
||||
resetFilters();
|
||||
for (const k of RELEASE_FILTER_KEYS) if (k !== "sort" && k !== "order") setFilter(k, "");
|
||||
};
|
||||
const sizeTag = sizeLabel(filters.minGb, filters.maxGb);
|
||||
|
||||
return (
|
||||
<section aria-labelledby="search-releases" className="min-w-0">
|
||||
@@ -142,8 +154,7 @@ export function ReleaseResults({
|
||||
{first ? (
|
||||
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
|
||||
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
|
||||
{relTime(first.fetchedAt)} · {first.tookMs} ms
|
||||
{first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}
|
||||
{relTime(first.fetchedAt)}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -167,8 +178,34 @@ export function ReleaseResults({
|
||||
|
||||
{first ? <CoverageNotice page={first} /> : null}
|
||||
|
||||
{first ? <QuickFilterBar facets={first.facets} value={quick} onChange={setQuick} /> : null}
|
||||
{first ? <Filters filters={filters} setFilter={setFilter} onReset={filtered ? resetAll : undefined} /> : null}
|
||||
{first ? (
|
||||
<QuickFilterBar
|
||||
facets={first.facets}
|
||||
value={quick}
|
||||
onChange={setQuick}
|
||||
onClearAll={clearAll}
|
||||
extraTags={
|
||||
sizeTag
|
||||
? [
|
||||
{
|
||||
key: "size",
|
||||
dimLabel: "大小",
|
||||
label: sizeTag,
|
||||
onRemove: () => {
|
||||
setFilter("minGb", "");
|
||||
setFilter("maxGb", "");
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
}
|
||||
aside={{
|
||||
label: "大小与排序",
|
||||
active: !!sizeTag || !!filters.sort || filters.order === "asc",
|
||||
content: (layout) => <SizeSortControls filters={filters} setFilter={setFilter} layout={layout} />,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
{res.isPending ? (
|
||||
@@ -202,6 +239,18 @@ export function ReleaseResults({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{first ? (
|
||||
<TechInfo
|
||||
className="mt-3"
|
||||
items={[
|
||||
{ label: "耗时", value: `${first.tookMs} ms` },
|
||||
{ label: "查询词", value: first.queries?.length ? first.queries.map((x) => `「${x}」`).join("") : null },
|
||||
{ label: "搜索词", value: first.q || null },
|
||||
...first.sites.map((x) => ({ label: x.siteName, value: `${x.count} 条 · ${x.tookMs} ms` })),
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
|
||||
</section>
|
||||
);
|
||||
@@ -258,100 +307,99 @@ function CoverageNotice({ page }: { page: ReleaseSearchPage }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Filters({
|
||||
/** 大小区间的标签文字:「5–20 GB」「≥ 5 GB」「≤ 20 GB」 */
|
||||
export function sizeLabel(min: string, max: string): string | null {
|
||||
if (min && max) return `${min}–${max} GB`;
|
||||
if (min) return `≥ ${min} GB`;
|
||||
if (max) return `≤ ${max} GB`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 大小区间 + 排序:桌面放在快速筛选同一行右侧,手机在抽屉里竖排 */
|
||||
function SizeSortControls({
|
||||
filters,
|
||||
setFilter,
|
||||
onReset,
|
||||
layout,
|
||||
}: {
|
||||
filters: ReleaseFilterState;
|
||||
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
|
||||
onReset?: () => void;
|
||||
layout: "inline" | "sheet";
|
||||
}) {
|
||||
const [minGb, setMinGb] = useState(filters.minGb);
|
||||
const [maxGb, setMaxGb] = useState(filters.maxGb);
|
||||
useEffect(() => setMinGb(filters.minGb), [filters.minGb]);
|
||||
useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]);
|
||||
const desc = filters.order !== "asc";
|
||||
const sheet = layout === "sheet";
|
||||
const commit = (k: "minGb" | "maxGb", v: string) => setFilter(k, v.trim());
|
||||
|
||||
return (
|
||||
<div className="mb-3 space-y-2.5">
|
||||
<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>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={minGb}
|
||||
onChange={(e) => setMinGb(e.target.value)}
|
||||
onBlur={() => setFilter("minGb", minGb.trim())}
|
||||
onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())}
|
||||
placeholder="最小"
|
||||
aria-label="最小大小(GB)"
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">–</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={maxGb}
|
||||
onChange={(e) => setMaxGb(e.target.value)}
|
||||
onBlur={() => setFilter("maxGb", maxGb.trim())}
|
||||
onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())}
|
||||
placeholder="最大"
|
||||
aria-label="最大大小(GB)"
|
||||
className="h-8 w-20"
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">GB</span>
|
||||
</div>
|
||||
<FilterSelect
|
||||
label="免费"
|
||||
value={filters.free}
|
||||
onChange={(v) => setFilter("free", v)}
|
||||
options={[
|
||||
{ value: "true", label: "只看免费" },
|
||||
{ value: "false", label: "排除免费" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="H&R"
|
||||
value={filters.hr}
|
||||
onChange={(v) => setFilter("hr", v)}
|
||||
options={[
|
||||
{ value: "true", label: "只看 H&R" },
|
||||
{ value: "false", label: "排除 H&R" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
|
||||
<SelectTrigger size="sm" aria-label="排序" className="min-w-32">
|
||||
<span className="text-muted-foreground">排序</span>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
|
||||
title={desc ? "降序" : "升序"}
|
||||
onClick={() => setFilter("order", desc ? "asc" : "")}
|
||||
>
|
||||
{desc ? <ArrowDownWideNarrow className="h-4 w-4" /> : <ArrowUpNarrowWide className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{onReset ? (
|
||||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||||
清除筛选
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
const size = (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={cn("text-meta text-muted-foreground", sheet && "w-10 shrink-0")}>大小</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={minGb}
|
||||
onChange={(e) => setMinGb(e.target.value)}
|
||||
onBlur={() => commit("minGb", minGb)}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit("minGb", minGb)}
|
||||
placeholder="最小"
|
||||
aria-label="最小大小(GB)"
|
||||
className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")}
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">–</span>
|
||||
<Input
|
||||
inputMode="decimal"
|
||||
value={maxGb}
|
||||
onChange={(e) => setMaxGb(e.target.value)}
|
||||
onBlur={() => commit("maxGb", maxGb)}
|
||||
onKeyDown={(e) => e.key === "Enter" && commit("maxGb", maxGb)}
|
||||
placeholder="最大"
|
||||
aria-label="最大大小(GB)"
|
||||
className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")}
|
||||
/>
|
||||
<span className="text-meta text-muted-foreground">GB</span>
|
||||
</div>
|
||||
);
|
||||
const sort = (
|
||||
<div className="flex items-center gap-1">
|
||||
{sheet ? <span className="w-10 shrink-0 text-meta text-muted-foreground">排序</span> : null}
|
||||
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
|
||||
<SelectTrigger size="sm" aria-label="排序" className={sheet ? "min-w-0 flex-1" : "min-w-28"}>
|
||||
{sheet ? null : <span className="text-muted-foreground">排序</span>}
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<SelectItem key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
|
||||
title={desc ? "降序" : "升序"}
|
||||
onClick={() => setFilter("order", desc ? "asc" : "")}
|
||||
>
|
||||
{desc ? <ArrowDownWideNarrow className="h-4 w-4" /> : <ArrowUpNarrowWide className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return sheet ? (
|
||||
<>
|
||||
{size}
|
||||
{sort}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{size}
|
||||
<span aria-hidden className="mx-0.5 h-4 w-px bg-border" />
|
||||
{sort}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) {
|
||||
|
||||
@@ -82,7 +82,6 @@ export default function SearchPage() {
|
||||
siteIds={siteIds}
|
||||
filters={f.values as ReleaseFilterState}
|
||||
setFilter={f.set}
|
||||
resetFilters={f.reset}
|
||||
onChangeSites={() => setPicking(true)}
|
||||
/>
|
||||
<SitePickerDialog
|
||||
|
||||
@@ -37,7 +37,7 @@ import { Button } from "~/components/ui/button.tsx";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx";
|
||||
import { FeedTable } from "~/components/workbench/feed-table.tsx";
|
||||
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
|
||||
import { categoryLabel, circuitLabel, hrOf, isFree, relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { categoryLabel, circuitLabel, isFree, relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { ReleaseRow } from "~/components/workbench/release-list.tsx";
|
||||
import { SnapshotPanel } from "~/components/workbench/snapshot-panel.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
@@ -61,12 +61,10 @@ const AGE = [
|
||||
];
|
||||
const GB = 1024 ** 3;
|
||||
|
||||
/** 服务端不支持的维度(H&R/做种/大小/上传时间)在已加载结果里筛选,界面上写明 */
|
||||
type LocalFilters = { hr: string; seeding: string; size: string; age: string };
|
||||
/** 服务端不支持的维度(做种/大小/上传时间)在已加载结果里筛选,界面上写明;H&R 在快速筛选里(POLISH-1) */
|
||||
type LocalFilters = { seeding: string; size: string; age: string };
|
||||
|
||||
function localMatch(r: WorkbenchRelease, f: LocalFilters, now: number) {
|
||||
if (f.hr === "no" && hrOf(r) !== "no") return false;
|
||||
if (f.hr === "yes" && hrOf(r) !== "yes") return false;
|
||||
if (f.seeding === "alive" && !(r.seeders && r.seeders > 0)) return false;
|
||||
if (f.seeding === "hot" && !(r.seeders && r.seeders >= 20)) return false;
|
||||
if (f.size) {
|
||||
@@ -116,7 +114,7 @@ export default function SiteWorkbenchPage() {
|
||||
const q = useDebounced(qInput.trim());
|
||||
const [category, setCategory] = useState("");
|
||||
const [resolution, setResolution] = useState("");
|
||||
const [local, setLocal] = useState<LocalFilters>({ hr: "", seeding: "", size: "", age: "" });
|
||||
const [local, setLocal] = useState<LocalFilters>({ seeding: "", size: "", age: "" });
|
||||
// 快速筛选(SEARCH-3):服务端按本地资源表数据统计与筛选,不触发站点请求
|
||||
const [quick, setQuick] = useQuickFilters("site");
|
||||
const qf = formatQuickFilter(quick);
|
||||
@@ -167,7 +165,7 @@ export default function SiteWorkbenchPage() {
|
||||
setCategory("");
|
||||
setQuick({});
|
||||
setResolution("");
|
||||
setLocal({ hr: "", seeding: "", size: "", age: "" });
|
||||
setLocal({ seeding: "", size: "", age: "" });
|
||||
};
|
||||
const anyFilter = !!(qInput || category || resolution || localActive || selectedCount(quick));
|
||||
|
||||
@@ -331,15 +329,6 @@ export default function SiteWorkbenchPage() {
|
||||
>
|
||||
<FilterSelect label="分类" value={category} onChange={setCategory} options={categories} />
|
||||
<FilterSelect label="识别" value={resolution} onChange={setResolution} options={RESOLUTION} />
|
||||
<FilterSelect
|
||||
label="H&R"
|
||||
value={local.hr}
|
||||
onChange={(v) => setLocal((l) => ({ ...l, hr: v }))}
|
||||
options={[
|
||||
{ value: "no", label: "无 H&R" },
|
||||
{ value: "yes", label: "有 H&R" },
|
||||
]}
|
||||
/>
|
||||
<FilterSelect
|
||||
label="做种"
|
||||
value={local.seeding}
|
||||
@@ -363,7 +352,8 @@ export default function SiteWorkbenchPage() {
|
||||
/>
|
||||
</FilterBar>
|
||||
|
||||
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} />
|
||||
{/* 「清除筛选」在上方筛选行,一并清掉快速筛选 */}
|
||||
<QuickFilterBar facets={pages[0]?.facets} value={quick} onChange={setQuick} hide={["site"]} hideClear />
|
||||
|
||||
{coverage || refreshFailed ? (
|
||||
<CoverageBanner coverage={coverage} refreshError={refreshFailed ? releases.error : null} />
|
||||
@@ -389,7 +379,7 @@ export default function SiteWorkbenchPage() {
|
||||
}
|
||||
description={
|
||||
all.length
|
||||
? "H&R、做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||
? "做种、大小、上传时间只筛选已加载的结果。加载更多或放宽条件。"
|
||||
: quickHidden
|
||||
? "减少快速筛选条件再看。"
|
||||
: coverage?.liveError
|
||||
|
||||
@@ -72,7 +72,10 @@ export const UnitMatch = z.object({
|
||||
slotId: Id.nullable(),
|
||||
state: UnitMatchState,
|
||||
assetId: Id.nullable(),
|
||||
/** 比对说明原文(界面放进技术信息) */
|
||||
reasons: z.array(z.string()),
|
||||
/** 差距原因码(`version_resolution:2160p` …),人话见 reasons.ts matchGapText */
|
||||
codes: z.array(z.string()),
|
||||
});
|
||||
export type UnitMatch = z.infer<typeof UnitMatch>;
|
||||
export const MatchCounts = z.object({ satisfied: NonNegInt, missing: NonNegInt, upgradeable: NonNegInt });
|
||||
|
||||
@@ -73,3 +73,51 @@ export function reasonSentence(raw: string | null | undefined, fallback = "处
|
||||
|
||||
/** 看起来像内部码(小写下划线 / 冒号)而不是给人看的句子 */
|
||||
export const looksLikeCode = (v: string | null | undefined) => !!v && /^[a-z0-9_]+([:.,][a-z0-9_:.,-]*)?$/.test(v);
|
||||
|
||||
// ── 入库版本与收录版本的差距(POLISH-1)──
|
||||
// 码由 @umt/domain matchVersions 给出,冒号后是参数:目标分辨率 / 来源 / 编码 / 字幕语言。
|
||||
|
||||
const SOURCE_NAME: Record<string, string> = { REMUX: "Remux", BluRay: "蓝光压制", "BluRay-Disc": "原盘" };
|
||||
const LANG_NAME: Record<string, string> = { "zh-Hans": "简中", "zh-Hant": "繁中", zh: "中文", en: "英文", ja: "日文" };
|
||||
|
||||
const GAPS: Record<string, (p: string) => string> = {
|
||||
version_resolution: (p) => (p ? `可换成 ${p}` : "分辨率不符合收录版本"),
|
||||
version_resolution_upgrade: (p) => (p ? `可换成 ${p}` : "分辨率还能更高"),
|
||||
version_source: (p) => (p ? `可换成 ${SOURCE_NAME[p] ?? p}` : "来源不符合收录版本"),
|
||||
version_source_upgrade: () => "可换成更好的来源",
|
||||
version_encode: () => "收录版本要蓝光压制",
|
||||
version_hdr: (p) => `缺 ${p || "HDR"} 版本`,
|
||||
version_sdr: () => "收录版本要 SDR",
|
||||
version_hdr_excluded: () => "HDR 格式被收录版本排除",
|
||||
version_codec: (p) => (p ? `可换成 ${p} 编码` : "编码被收录版本排除"),
|
||||
version_subtitles: (p) => {
|
||||
const langs = p
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((l) => LANG_NAME[l] ?? l)
|
||||
.join("/");
|
||||
if (!langs) return "缺字幕";
|
||||
return /^[\x20-\x7e]+$/.test(langs) ? `缺 ${langs} 字幕` : `缺${langs}字幕`;
|
||||
},
|
||||
version_size: () => "体积不在收录范围",
|
||||
version_edition: () => "剪辑版本不符合",
|
||||
version_group: () => "制作组被排除",
|
||||
version_content_type: () => "内容类型不符合",
|
||||
version_rules: () => "不符合收录版本的规则",
|
||||
version_spec_unknown: () => "认不出规格",
|
||||
version_priority: () => "还有更好的版本可换",
|
||||
};
|
||||
|
||||
/** 一个差距码 → 短句("可换成 2160p"、"缺 HDR 版本");不认识的码返回 null */
|
||||
export function matchGapText(code: string | null | undefined): string | null {
|
||||
if (!code) return null;
|
||||
const i = code.indexOf(":");
|
||||
const head = i < 0 ? code : code.slice(0, i);
|
||||
const fn = GAPS[head];
|
||||
return fn ? fn(i < 0 ? "" : code.slice(i + 1)) : null;
|
||||
}
|
||||
|
||||
/** 一组差距码 → 去重后的短句列表 */
|
||||
export function matchGapTexts(codes: readonly string[] | null | undefined): string[] {
|
||||
return [...new Set((codes ?? []).map(matchGapText).filter((t): t is string => !!t))];
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type FacetCount = z.infer<typeof FacetCount>;
|
||||
* 快速筛选参数 qf:`维度:值,值;维度:值`,值经 encodeURIComponent。
|
||||
* 维度:resolution(2160p/1080p/720p/other)、videoCodec(H.265/H.264/AV1/other)、
|
||||
* releaseType(disc/remux/encode/webdl/hdtv/other)、site(站点 ID)、promotion(free/2x/50%/other/none)、
|
||||
* hdr(DV/HDR10/HDR10+/other/SDR)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
|
||||
* hdr(DV/HDR10/HDR10+/other/SDR)、hr(yes/no/unknown)、releaseGroup(制作组名,不分大小写)、season(季号)、episode(集号 / pack 整季)。
|
||||
*/
|
||||
export const QuickFilterParam = z.string().max(4000).optional();
|
||||
|
||||
@@ -24,6 +24,8 @@ export const ReleaseQuickFacets = z.object({
|
||||
site: z.array(FacetCount),
|
||||
promotion: z.array(FacetCount),
|
||||
hdr: z.array(FacetCount),
|
||||
/** H&R:有 / 无 / 未知(POLISH-1) */
|
||||
hr: z.array(FacetCount),
|
||||
/** 计数最多的前 20 个 + 已选的 */
|
||||
releaseGroup: z.array(FacetCount),
|
||||
/** 没有剧集资源时为空 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeCode, reasonSentence, reasonText } from "../src/reasons.ts";
|
||||
import { looksLikeCode, matchGapText, matchGapTexts, reasonSentence, reasonText } from "../src/reasons.ts";
|
||||
|
||||
describe("reasonText(DEAI-2 原因码人话化)", () => {
|
||||
it("裸码、blocked:多码、带后缀的码都能翻译", () => {
|
||||
@@ -24,3 +24,21 @@ describe("reasonText(DEAI-2 原因码人话化)", () => {
|
||||
expect(looksLikeCode("Connection refused")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchGapText(POLISH-1 入库版本差距)", () => {
|
||||
it("带参数的码转成短句", () => {
|
||||
expect(matchGapText("version_resolution:2160p")).toBe("可换成 2160p");
|
||||
expect(matchGapText("version_hdr")).toBe("缺 HDR 版本");
|
||||
expect(matchGapText("version_hdr:DV")).toBe("缺 DV 版本");
|
||||
expect(matchGapText("version_source:REMUX")).toBe("可换成 Remux");
|
||||
expect(matchGapText("version_subtitles:zh-Hans")).toBe("缺简中字幕");
|
||||
expect(matchGapText("version_subtitles:chs")).toBe("缺 chs 字幕");
|
||||
});
|
||||
|
||||
it("不认识的码返回 null;列表去重", () => {
|
||||
expect(matchGapText("nope")).toBeNull();
|
||||
expect(matchGapTexts(["version_resolution:2160p", "version_resolution_upgrade:2160p", "nope"])).toEqual([
|
||||
"可换成 2160p",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
qualityTier,
|
||||
RESOLUTION_RANK,
|
||||
} from "../slot/evaluate.ts";
|
||||
import type { SlotSpec } from "../slot/types.ts";
|
||||
import type { SlotSpec, Violation } from "../slot/types.ts";
|
||||
|
||||
// LIBV-1 入库版本:一集(或一部电影)可以挂多个本地文件,每个文件是一个"版本"。
|
||||
// 版本规格 = 文件名解析 + ffprobe 结果合并:技术事实(分辨率、编码、HDR、音轨、字幕、时长)以探测为准,
|
||||
@@ -354,8 +354,10 @@ export interface UnitMatch {
|
||||
state: UnitMatchState;
|
||||
/** 满足目标的最好版本(satisfied / 可升级但已达标时);都不达标时为最接近的版本 */
|
||||
assetId: string | null;
|
||||
/** 可读原因:不达标的条件、升级余量 */
|
||||
/** 可读原因:不达标的条件、升级余量(原文,界面放进技术信息) */
|
||||
reasons: string[];
|
||||
/** 差距原因码(`version_resolution:2160p`、`version_hdr` …),界面经 @umt/contracts matchGapText 转成人话 */
|
||||
codes: string[];
|
||||
}
|
||||
|
||||
export interface LocalVersion {
|
||||
@@ -429,7 +431,7 @@ export function matchVersions(
|
||||
opts: { isEpisode: boolean },
|
||||
): UnitMatch {
|
||||
const slotId = target.id ?? null;
|
||||
if (versions.length === 0) return { slotId, state: "missing", assetId: null, reasons: [] };
|
||||
if (versions.length === 0) return { slotId, state: "missing", assetId: null, reasons: [], codes: [] };
|
||||
const spec = localSlotSpec(target, opts.isEpisode);
|
||||
const evals = versions.map((v) => {
|
||||
const r = pseudoRelease(v);
|
||||
@@ -444,19 +446,27 @@ export function matchVersions(
|
||||
const best = ok[0];
|
||||
if (!best) {
|
||||
const near = [...evals].sort(better)[0]!;
|
||||
const reasons = near.ev.decision === "reject" ? uniq(near.ev.violations.map(describeViolation)) : ["规格未知"];
|
||||
return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons };
|
||||
const rejected = near.ev.decision === "reject" ? near.ev.violations : null;
|
||||
const reasons = rejected ? uniq(rejected.map(describeViolation)) : ["规格未知"];
|
||||
const codes = rejected ? uniq(rejected.map((v) => gapCode(v, target))) : ["version_spec_unknown"];
|
||||
return { slotId, state: "upgradeable", assetId: near.v.assetId, reasons, codes };
|
||||
}
|
||||
const up = target.upgrade;
|
||||
if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
if (!up?.enabled) return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] };
|
||||
const tiered = !!target.soft?.tiers?.length || !!target.rules?.groups.length;
|
||||
const basis = up.basis ?? (tiered ? "priority" : "tier");
|
||||
if (basis === "priority") {
|
||||
const pri = best.ev.decision === "accept" ? (best.ev.pri ?? currentPri(spec, best.r)) : 0;
|
||||
const goal = up.targetPri ?? 100;
|
||||
return pri < goal
|
||||
? { slotId, state: "upgradeable", assetId: best.v.assetId, reasons: [`优先级 ${pri},目标 ${goal}`] }
|
||||
: { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
? {
|
||||
slotId,
|
||||
state: "upgradeable",
|
||||
assetId: best.v.assetId,
|
||||
reasons: [`优先级 ${pri},目标 ${goal}`],
|
||||
codes: ["version_priority"],
|
||||
}
|
||||
: { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] };
|
||||
}
|
||||
const ceil = ceilingOf(target);
|
||||
const belowCeiling = up.ceilingTier === undefined || best.tier.source < up.ceilingTier;
|
||||
@@ -468,6 +478,62 @@ export function matchVersions(
|
||||
state: "upgradeable",
|
||||
assetId: best.v.assetId,
|
||||
reasons: [bySource ? "来源还能升级" : "分辨率还能升级"],
|
||||
codes: [bySource ? "version_source_upgrade" : withParam("version_resolution_upgrade", topResolution(target))],
|
||||
};
|
||||
return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [] };
|
||||
return { slotId, state: "satisfied", assetId: best.v.assetId, reasons: [], codes: [] };
|
||||
}
|
||||
|
||||
const withParam = (code: string, p: string | null | undefined) => (p ? `${code}:${p}` : code);
|
||||
|
||||
/** 目标允许的最高分辨率名 */
|
||||
function topResolution(spec: SlotSpec, allowed?: readonly string[]): string | null {
|
||||
const list = (allowed ?? spec.hard.resolutions ?? Object.keys(RESOLUTION_RANK)) as Resolution[];
|
||||
const known = list.filter((r) => RESOLUTION_RANK[r] !== undefined);
|
||||
return known.sort((a, b) => RESOLUTION_RANK[b] - RESOLUTION_RANK[a])[0] ?? null;
|
||||
}
|
||||
|
||||
/** 目标允许的最好来源 */
|
||||
function topSource(spec: SlotSpec, allowed: readonly string[]): string | null {
|
||||
const tiers: Record<string, number> = { ...DEFAULT_SOURCE_TIERS, ...spec.upgrade?.sourceTiers };
|
||||
return [...allowed].sort((a, b) => (tiers[b] ?? -1) - (tiers[a] ?? -1))[0] ?? null;
|
||||
}
|
||||
|
||||
const strList = (v: unknown): string[] => (Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
||||
|
||||
/**
|
||||
* 本地版本与收录版本的差距 → 原因码(POLISH-1)。码表与文案在 @umt/contracts reasons.ts(matchGapText),
|
||||
* 冒号后是参数(目标分辨率、来源、编码、字幕语言)。
|
||||
*/
|
||||
export function gapCode(v: Violation, spec: SlotSpec): string {
|
||||
const expected = strList(v.expected);
|
||||
switch (`${v.field}.${v.code}`) {
|
||||
case "resolution.not_allowed":
|
||||
return withParam("version_resolution", topResolution(spec, expected));
|
||||
case "source.not_allowed":
|
||||
return withParam("version_source", topSource(spec, expected));
|
||||
case "source.excluded":
|
||||
return "version_source";
|
||||
case "source.not_encode":
|
||||
return "version_encode";
|
||||
case "hdr.missing_required":
|
||||
return expected.length && expected.every((h) => h === "DV") ? "version_hdr:DV" : "version_hdr";
|
||||
case "hdr.not_sdr":
|
||||
return "version_sdr";
|
||||
case "hdr.excluded":
|
||||
return "version_hdr_excluded";
|
||||
case "codec.not_allowed":
|
||||
return withParam("version_codec", expected[0]);
|
||||
case "codec.excluded":
|
||||
return "version_codec";
|
||||
case "subtitles.missing_language":
|
||||
return withParam("version_subtitles", expected.join("/"));
|
||||
case "cut.not_allowed":
|
||||
return "version_edition";
|
||||
case "group.excluded":
|
||||
return "version_group";
|
||||
case "contentType.not_allowed":
|
||||
return "version_content_type";
|
||||
default:
|
||||
return v.field === "size" ? "version_size" : "version_rules";
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export const FACET_DIMS = [
|
||||
"site",
|
||||
"promotion",
|
||||
"hdr",
|
||||
"hr",
|
||||
"releaseGroup",
|
||||
"season",
|
||||
"episode",
|
||||
@@ -46,6 +47,8 @@ export interface FacetInput {
|
||||
uploadFactor: number | null;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
/** H&R 三态;缺省 / null 按未知 */
|
||||
hr?: boolean | "unknown" | null;
|
||||
}
|
||||
|
||||
/** 固定档位(顺序即展示顺序);"other" 类档位只在有资源时列出 */
|
||||
@@ -84,6 +87,11 @@ const FIXED: Partial<Record<FacetDim, readonly { value: string; label: string; h
|
||||
{ value: "other", label: "其他 HDR", hideEmpty: true },
|
||||
{ value: "SDR", label: "SDR" },
|
||||
],
|
||||
hr: [
|
||||
{ value: "yes", label: "有" },
|
||||
{ value: "no", label: "无" },
|
||||
{ value: "unknown", label: "未知", hideEmpty: true },
|
||||
],
|
||||
};
|
||||
|
||||
/** 制作组只列计数最多的前 N 个(已选的总会列出) */
|
||||
@@ -143,6 +151,10 @@ export function hdrBuckets(hdr: readonly string[]): string[] {
|
||||
return [...out];
|
||||
}
|
||||
|
||||
export function hrBucket(hr: FacetInput["hr"]): string {
|
||||
return hr === true ? "yes" : hr === false ? "no" : "unknown";
|
||||
}
|
||||
|
||||
function seasonValues(r: FacetInput): string[] {
|
||||
if (r.season === null || r.season === undefined) return [];
|
||||
const end =
|
||||
@@ -172,6 +184,7 @@ export function facetValues(r: FacetInput, now: number): Record<FacetDim, string
|
||||
site: [r.siteId],
|
||||
promotion: promotionBuckets(r.promotion, now),
|
||||
hdr: hdrBuckets(r.hdr),
|
||||
hr: [hrBucket(r.hr)],
|
||||
releaseGroup: r.releaseGroup ? [r.releaseGroup] : [],
|
||||
season: seasonValues(r),
|
||||
episode: episodeValues(r),
|
||||
|
||||
@@ -145,6 +145,7 @@ describe("与版本目标比对", () => {
|
||||
state: "missing",
|
||||
assetId: null,
|
||||
reasons: [],
|
||||
codes: [],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,12 +159,29 @@ describe("与版本目标比对", () => {
|
||||
expect(m.state).toBe("upgradeable");
|
||||
expect(m.assetId).toBe("bd");
|
||||
expect(m.reasons.join(" ")).toMatch(/1080p/);
|
||||
expect(m.codes).toEqual(["version_resolution:2160p"]);
|
||||
});
|
||||
|
||||
it("差距原因码:HDR / 字幕 / 来源带目标参数", () => {
|
||||
const hdr: SlotSpec = { id: "h", name: "h", hard: { hdr: { anyOf: ["DV", "HDR10"] } } };
|
||||
expect(matchVersions(hdr, [v("bd", BD_FILE)], { isEpisode: true }).codes).toEqual(["version_hdr"]);
|
||||
const dvOnly: SlotSpec = { id: "d", name: "d", hard: { hdr: { anyOf: ["DV"] } } };
|
||||
expect(matchVersions(dvOnly, [v("bd", BD_FILE)], { isEpisode: true }).codes).toEqual(["version_hdr:DV"]);
|
||||
const remux: SlotSpec = { id: "r", name: "r", hard: { sources: ["REMUX", "BluRay-Disc"] } };
|
||||
expect(matchVersions(remux, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).codes).toEqual([
|
||||
"version_source:REMUX",
|
||||
]);
|
||||
});
|
||||
|
||||
it("达标但升级策略还有余量 = upgradeable;到顶 = satisfied", () => {
|
||||
const up: SlotSpec = { id: "s", name: "x", hard: {}, upgrade: { enabled: true } };
|
||||
const web = matchVersions(up, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true });
|
||||
expect(web).toMatchObject({ state: "upgradeable", assetId: "dv", reasons: ["来源还能升级"] });
|
||||
expect(web).toMatchObject({
|
||||
state: "upgradeable",
|
||||
assetId: "dv",
|
||||
reasons: ["来源还能升级"],
|
||||
codes: ["version_source_upgrade"],
|
||||
});
|
||||
const capped: SlotSpec = { ...up, upgrade: { enabled: true, ceilingTier: 2 } };
|
||||
expect(matchVersions(capped, [v("dv", DV_FILE, PROBE_DV)], { isEpisode: true }).state).toBe("satisfied");
|
||||
const onlyWeb: SlotSpec = { ...up, hard: { sources: ["WEB-DL"] } };
|
||||
|
||||
@@ -100,6 +100,19 @@ describe("release-facets", () => {
|
||||
expect(ids(run({ promotion: ["2x"] }))).toEqual(["4"]);
|
||||
});
|
||||
|
||||
it("H&R 三态分面:缺省按未知;未知档只在有资源时列出", () => {
|
||||
const hrItems = [mk("y", { hr: true }), mk("n", { hr: false }), mk("n2", { hr: false }), mk("u")];
|
||||
const r = applyFacets(hrItems, (x) => x, {}, { now: NOW });
|
||||
expect(r.facets.hr).toEqual([
|
||||
{ value: "yes", label: "有", count: 1 },
|
||||
{ value: "no", label: "无", count: 2 },
|
||||
{ value: "unknown", label: "未知", count: 1 },
|
||||
]);
|
||||
expect(ids(applyFacets(hrItems, (x) => x, { hr: ["no", "unknown"] }, { now: NOW }))).toEqual(["n", "n2", "u"]);
|
||||
const known = applyFacets([mk("y", { hr: true })], (x) => x, {}, { now: NOW });
|
||||
expect(known.facets.hr.map((o) => o.value)).toEqual(["yes", "no"]);
|
||||
});
|
||||
|
||||
it("已选但没有结果的值仍列出(计数 0)", () => {
|
||||
const r = run({ releaseGroup: ["NoSuch"], site: ["zzz"] });
|
||||
expect(r.items).toEqual([]);
|
||||
|
||||
Reference in new issue
Block a user