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

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

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

No files matched your search

+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>
);