Merge branch 'polish1'

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
rootandClaude Fable 5.1 committed 2026-09-29 22:06:58 +08:00
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) {
+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 () => {
+150 -112
View File
@@ -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 }) {
-1
View File
@@ -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
+8 -18
View File
@@ -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 });
+48
View File
@@ -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))];
}
+3 -1
View File
@@ -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),
/** 没有剧集资源时为空 */
+19 -1
View File
@@ -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",
]);
});
});
+75 -9
View File
@@ -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),
+19 -1
View File
@@ -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([]);