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:
1 parent
12bc4664b6
commit
71b129d85a
19 files changed
+668
-290
No files matched your search
@@ -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>
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user