feat(web): 资源搜索结果改版(规格标签、促销前置、数字对齐、按作品分组、规则判定)

参考 MP 资源卡片:促销放在标题前(免费用 success,其余折扣中性加粗,带剩余时间);
规格拆成中性小标签(季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 语言 → 制作组);
站点 / 大小 / 做种·下载 / 发布时间宽屏定宽对齐、窄屏折行,做种加粗、为 0 时弱化;
结果涉及多部作品时按作品分组(组头 = 作品名 + 年份 + 库存状态),只有一部时不重复作品名;
每行显示服务端规则判定(命中第 N 档 / 不满足 / 与库里版本比较)。保留工作台、委托智能体、下载。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 12:04:37 +08:00
1 parent a7d20ca370
commit 39ec8185e9
3 files changed
+542 -46

No files matched your search

@@ -11,6 +11,7 @@ import {
PackageSearch, PackageSearch,
RefreshCw, RefreshCw,
TriangleAlert, TriangleAlert,
UsersRound,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -25,7 +26,7 @@ import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx"; import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx";
import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx"; import { ReleaseDetailSheet } from "~/components/workbench/release-detail-sheet.tsx";
import { relTime } from "~/components/workbench/release-fields.ts"; import { absTime, relTime, remainingLabel } from "~/components/workbench/release-fields.ts";
import { errorMessage } from "~/lib/api.ts"; import { errorMessage } from "~/lib/api.ts";
import { cn, formatBytes } from "~/lib/utils.ts"; import { cn, formatBytes } from "~/lib/utils.ts";
import { QuickFilterBar } from "./quick-filter-bar.tsx"; import { QuickFilterBar } from "./quick-filter-bar.tsx";
@@ -37,11 +38,25 @@ import {
useQuickFilters, useQuickFilters,
} from "./quick-filter-state.ts"; } from "./quick-filter-state.ts";
import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx";
import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts"; import {
gbToBytes,
groupByWork,
hrBadge,
INVENTORY_STATE,
promoBadge,
releaseSpecTags,
SITE_STATUS,
SORT_OPTIONS,
verdictView,
vsLibraryLabel,
type WorkGroup,
} from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/ // 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页; // 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。 // 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */ /** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = { export type ReleaseFilterState = {
@@ -237,11 +252,7 @@ export function ReleaseResults({
className="py-12" className="py-12"
/> />
) : ( ) : (
<ul className="divide-y divide-border/60"> <ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
{paged.items?.map((r) => (
<ReleaseRow key={r.key} r={r} onOpen={() => setSelected(r)} onDownload={() => setDl(r)} />
))}
</ul>
)} )}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null} {rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div> </div>
@@ -420,14 +431,141 @@ function SizeSortControls({
); );
} }
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) { /** 作品组头:作品名 + 年份 + 未确认 + 库存状态 + 条数 */
const promo = promoLabel(r.promotion.type); function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null; const inv = g.inventory ? INVENTORY_STATE[g.inventory.state] : null;
const p = r.parsed;
const tech = [p.resolution, p.source, p.videoCodec, ...p.hdr, p.releaseGroup].filter(Boolean).join(" · ");
return ( return (
<li className="px-4 py-3"> <div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 bg-foreground/[0.03] px-4 py-2">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1"> {g.entity ? (
<>
<Link
to={`/console/library/${g.entity.id}`}
className="min-w-0 truncate text-caption font-semibold text-foreground underline-offset-4 hover:underline"
>
{g.entity.title}
{g.entity.year ? ` (${g.entity.year})` : ""}
</Link>
{g.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
</>
) : (
<span className="text-caption font-semibold text-muted-foreground">未关联本地作品</span>
)}
<span className="ml-auto text-meta text-muted-foreground tabular-nums">{count} 条</span>
</div>
);
}
/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
function ReleaseList({
rows,
onOpen,
onDownload,
}: {
rows: SearchRelease[];
onOpen: (r: SearchRelease) => void;
onDownload: (r: SearchRelease) => void;
}) {
const { grouped, groups } = groupByWork(rows);
const single = !grouped && groups[0]?.entity ? groups[0] : null;
const row = (r: SearchRelease) => (
<ReleaseRow key={r.key} r={r} onOpen={() => onOpen(r)} onDownload={() => onDownload(r)} />
);
if (!grouped)
return (
<>
{single ? <WorkHeader g={single} count={single.items.length} /> : null}
<ul className="divide-y divide-border/60">{rows.map(row)}</ul>
</>
);
return (
<div className="divide-y divide-border/60">
{groups.map((g) => (
<section key={g.key ?? "loose"} aria-label={g.entity ? g.entity.title : "未关联本地作品"}>
<WorkHeader g={g} count={g.items.length} />
<ul className="divide-y divide-border/60 border-t border-border/60">{g.items.map(row)}</ul>
</section>
))}
</div>
);
}
/** 促销放在标题前:免费用 success(属状态),其余折扣中性加粗;带剩余时间 */
function PromoTag({ r }: { r: SearchRelease }) {
const promo = promoBadge(r);
if (!promo) return null;
const left = remainingLabel(r.promotion.expiresAt);
const text = left ? `${promo.label} · ${left}` : promo.label;
const title = r.promotion.expiresAt ? `促销截止 ${absTime(r.promotion.expiresAt)}` : undefined;
return promo.tone === "success" ? (
<span title={title} className="shrink-0">
<StatusBadge tone="success" className="font-semibold">
{text}
</StatusBadge>
</span>
) : (
<span
title={title}
className="inline-flex shrink-0 items-center rounded-full border border-foreground/30 px-2 py-0.5 text-meta font-semibold whitespace-nowrap text-foreground"
>
{text}
</span>
);
}
/** 规格标签:中性小标签,固定顺序;制作组带图标区分 */
function SpecTagList({ r }: { r: SearchRelease }) {
const tags = releaseSpecTags(r.parsed);
if (!tags.length) return null;
return (
<span className="flex flex-wrap gap-1">
{tags.map((t) => (
<span
key={t.key}
title={t.title}
className={cn(
"inline-flex items-center gap-0.5 rounded-md border px-1.5 text-meta leading-5 whitespace-nowrap",
t.dim === "resolution" || t.dim === "hdr" || t.dim === "episode"
? "border-border text-foreground"
: "border-border/70 text-muted-foreground",
)}
>
{t.dim === "group" ? <UsersRound aria-hidden className="h-3 w-3" /> : null}
{t.label}
</span>
))}
</span>
);
}
/** 规则判定 + 与库里版本比较 */
function VerdictLine({ r }: { r: SearchRelease }) {
const v = r.ruleVerdict;
if (!v) return null;
const view = verdictView(v);
const vs = vsLibraryLabel(v);
return (
<span className="flex flex-wrap items-center gap-1">
<span title={view.title || undefined}>
<StatusBadge tone={view.tone}>{view.label}</StatusBadge>
</span>
{vs ? <StatusBadge>{vs}</StatusBadge> : null}
</span>
);
}
/**
* 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
* 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
*/
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
const hr = hrBadge(r);
const seedless = r.seeders === 0 || r.seeders === null;
return (
<li className="flex min-w-0 flex-col gap-2 px-4 py-3 lg:flex-row lg:items-start lg:gap-6">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex min-w-0 items-start gap-2">
<PromoTag r={r} />
<button <button
type="button" type="button"
onClick={onOpen} onClick={onOpen}
@@ -436,44 +574,37 @@ function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () =>
> >
{r.title} {r.title}
</button> </button>
<span className="flex flex-wrap gap-1"> {hr ? (
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null} <span className="shrink-0">
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null} <StatusBadge tone={hr.tone}>{hr.label}</StatusBadge>
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
</span> </span>
) : null}
</div> </div>
{r.subtitle ? <p className="mt-0.5 line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null} {r.subtitle ? <p className="line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-meta text-muted-foreground tabular-nums"> <SpecTagList r={r} />
<span className="font-medium text-foreground">{r.siteName}</span> <VerdictLine r={r} />
{r.category.name ? <span>{r.category.name}</span> : null} </div>
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span> <div className="flex shrink-0 flex-col gap-2 lg:items-end">
<span className="inline-flex items-center gap-0.5" title="做种 / 下载"> <div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums lg:flex-nowrap">
<ArrowUp className="h-3 w-3" /> <span className="truncate font-medium text-foreground lg:w-24" title={r.category.name ?? undefined}>
{r.siteName}
</span>
<span className="lg:w-16 lg:text-right">{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5 lg:w-28" title="做种 / 下载">
<ArrowUp aria-label="做种" className="h-3.5 w-3.5" />
<span className={cn("text-body", seedless ? "text-muted-foreground" : "font-semibold text-foreground")}>
{r.seeders ?? "—"} {r.seeders ?? "—"}
<ArrowDown className="ml-1 h-3 w-3" /> </span>
<ArrowDown aria-label="下载" className="ml-1.5 h-3 w-3" />
{r.leechers ?? "—"} {r.leechers ?? "—"}
</span> </span>
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span> <span
{tech ? <span className="break-all">{tech}</span> : null} className="lg:w-20 lg:text-right"
</div> title={r.uploadedAt ? `发布于 ${absTime(r.uploadedAt)}` : "发布时间未知"}
<div className="mt-2 flex flex-wrap items-center justify-between gap-2">
<span className="flex min-w-0 flex-wrap items-center gap-1.5 text-meta">
{r.entity ? (
<>
<Link
to={`/console/library/${r.entity.id}`}
className="truncate text-foreground underline-offset-4 hover:underline"
> >
{r.entity.title} {relTime(r.uploadedAt)}
{r.entity.year ? ` (${r.entity.year})` : ""}
</Link>
{r.entityMatch === "alias" ? <StatusBadge>未确认</StatusBadge> : null}
{inv ? <StatusBadge tone={inv.tone}>{inv.label}</StatusBadge> : null}
</>
) : (
<span className="text-muted-foreground">未关联本地作品</span>
)}
</span> </span>
</div>
<span className="flex flex-wrap items-center gap-1"> <span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? ( {r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm"> <Button asChild variant="ghost" size="sm">
@@ -0,0 +1,203 @@
import type { SearchRelease, SearchRuleVerdict } from "@umt/contracts";
import { describe, expect, it } from "vitest";
import { groupByWork, hrBadge, promoBadge, releaseSpecTags, verdictView, vsLibraryLabel } from "./search-labels.ts";
// SEARCH-5 资源行:规格标签拆分与顺序、促销 / H&R、按作品分组、规则判定文案。
const E1 = "11111111-1111-4111-8111-111111111111";
const E2 = "22222222-2222-4222-8222-222222222222";
const SLOT = "33333333-3333-4333-8333-333333333333";
const parsed = (p: Partial<SearchRelease["parsed"]> = {}): SearchRelease["parsed"] => ({
titleCn: null,
titleEn: null,
year: null,
season: null,
episodes: null,
resolution: null,
source: null,
videoCodec: null,
hdr: [],
releaseGroup: null,
...p,
});
const rel = (key: string, p: Partial<SearchRelease> = {}): SearchRelease => ({
key,
sourceReleaseId: null,
siteId: E1,
siteName: "站",
siteReleaseId: key,
title: key,
subtitle: null,
category: { id: null, name: null },
sizeBytes: null,
seeders: null,
leechers: null,
snatched: null,
promotion: { type: "none", downloadFactor: 1, uploadFactor: 1, expiresAt: null },
free: false,
hr: false,
uploadedAt: null,
detailUrl: "http://x/",
observedAt: "2026-10-01T00:00:00.000Z",
parsed: parsed(),
mediaEntityId: null,
entityMatch: null,
entity: null,
inventory: null,
ruleVerdict: null,
...p,
});
const verdict = (v: Partial<SearchRuleVerdict> = {}): SearchRuleVerdict => ({
slotId: SLOT,
slotName: "主版本",
slotCount: 1,
outcome: "hit",
tier: 1,
tierName: "画质·第1层",
failed: [],
details: [],
vsLibrary: null,
...v,
});
describe("规格标签", () => {
it("固定顺序:季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 语言 → 制作组", () => {
const tags = releaseSpecTags(
parsed({
season: 1,
episodes: { start: 1, end: 8 },
releaseGroup: "CHDWEB",
subtitles: ["zh-Hans", "en"],
audioLangs: ["cmn"],
audio: ["DDP 5.1", "Atmos"],
hdr: ["DV", "HDR10"],
videoCodec: "H.265",
source: "WEB-DL",
resolution: "2160p",
}),
);
expect(tags.map((t) => t.label)).toEqual([
"S01E01-E08",
"2160p",
"WEB-DL",
"H.265",
"DV",
"HDR10",
"DDP 5.1",
"Atmos",
"国语",
"简中",
"英字",
"CHDWEB",
]);
expect(tags.map((t) => t.dim)).toEqual([
"episode",
"resolution",
"source",
"codec",
"hdr",
"hdr",
"audio",
"audio",
"lang",
"lang",
"lang",
"group",
]);
expect(tags[1]!.title).toBe("分辨率:2160p");
});
it("没解析出的维度不出标签;同维度同名去重;多季合集写 S01-S03", () => {
expect(releaseSpecTags(parsed())).toEqual([]);
expect(releaseSpecTags(parsed({ hdr: ["DV", "DV"] })).map((t) => t.label)).toEqual(["DV"]);
expect(releaseSpecTags(parsed({ season: 1, seasonEnd: 3 }))[0]!.label).toBe("S01-S03");
});
});
describe("促销与 H&R", () => {
it("免费 / 2x 免费用 success;50%、2x 上传中性;无促销不显示", () => {
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "free" }, free: true }))).toEqual({
label: "免费",
tone: "success",
});
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "2xfree" }, free: true }))).toEqual({
label: "2x 免费",
tone: "success",
});
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "50%" } }))).toEqual({
label: "50%",
tone: "neutral",
});
expect(promoBadge(rel("a", { promotion: { ...rel("a").promotion, type: "2x" } }))?.tone).toBe("neutral");
expect(promoBadge(rel("a"))).toBeNull();
});
it("H&R:有 → warning;未知 → 中性;没有 → 不显示", () => {
expect(hrBadge({ hr: true })).toEqual({ label: "H&R", tone: "warning" });
expect(hrBadge({ hr: "unknown" })).toEqual({ label: "H&R 未知", tone: "neutral" });
expect(hrBadge({ hr: false })).toBeNull();
});
});
describe("按作品分组", () => {
const work = (id: string, title: string) => ({ id, title, year: 2024, kind: "movie" as const });
it("多部作品按首次出现分组,未关联的放最后", () => {
const rows = [
rel("a", { entity: work(E1, "沙丘2"), entityMatch: "alias" }),
rel("b"),
rel("c", { entity: work(E2, "奥本海默"), entityMatch: "resolved" }),
rel("d", { entity: work(E1, "沙丘2"), entityMatch: "resolved" }),
];
const { grouped, groups } = groupByWork(rows);
expect(grouped).toBe(true);
expect(groups.map((g) => [g.key, g.items.map((r) => r.key)])).toEqual([
[E1, ["a", "d"]],
[E2, ["c"]],
[null, ["b"]],
]);
// 组里有已识别的,组头不再标「未确认」
expect(groups[0]!.entityMatch).toBe("resolved");
});
it("只有一部作品 / 全部未关联时不分组", () => {
expect(
groupByWork([rel("a", { entity: work(E1, "沙丘2") }), rel("b", { entity: work(E1, "沙丘2") })]),
).toMatchObject({
grouped: false,
groups: [{ key: E1 }],
});
expect(groupByWork([rel("a"), rel("b")])).toMatchObject({ grouped: false, groups: [{ key: null }] });
expect(groupByWork([])).toEqual({ grouped: false, groups: [] });
});
});
describe("规则判定文案", () => {
it("命中档位 / 满足 / 不满足 / 无法判断", () => {
expect(verdictView(verdict())).toMatchObject({ label: "命中第 1 档", tone: "success" });
expect(verdictView(verdict({ tier: null, tierName: null })).label).toBe("满足收录条件");
const miss = verdictView(
verdict({ outcome: "miss", tier: null, failed: ["分辨率", "来源"], details: ["分辨率 720p 不在允许列表"] }),
);
expect(miss).toMatchObject({ label: "不满足:分辨率、来源", tone: "neutral" });
expect(miss.title).toContain("分辨率 720p 不在允许列表");
expect(verdictView(verdict({ outcome: "miss", tier: null, failed: ["分辨率", "来源", "编码", "HDR"] })).label).toBe(
"不满足:分辨率、来源、编码 等 4 项",
);
expect(verdictView(verdict({ outcome: "unknown", tier: null, failed: ["分辨率"] })).label).toBe("无法判断:分辨率");
});
it("多个收录版本时带收录版本名", () => {
expect(verdictView(verdict({ slotCount: 2, slotName: "4K" })).label).toBe("「4K」命中第 1 档");
});
it("与库里版本比较", () => {
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "higher", steps: 1 } }))).toBe("比库里版本高 1 档");
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "higher", steps: null } }))).toBe("比库里版本好");
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "same", steps: 0 } }))).toBe("与库里版本同档");
expect(vsLibraryLabel(verdict({ vsLibrary: { cmp: "lower", steps: 2 } }))).toBe("不如库里版本");
expect(vsLibraryLabel(verdict())).toBeNull();
});
});
+163 -1
View File
@@ -1,4 +1,12 @@
import type { LocalMediaHit, MediaKind, SearchInventoryState, SearchSiteStatus } from "@umt/contracts"; import type {
LocalMediaHit,
MediaKind,
SearchInventoryState,
SearchRelease,
SearchReleaseParsed,
SearchRuleVerdict,
SearchSiteStatus,
} from "@umt/contracts";
import { Compass, type LucideIcon, Zap } from "lucide-react"; import { Compass, type LucideIcon, Zap } from "lucide-react";
import type { StatusTone } from "~/components/console/status-badge.tsx"; import type { StatusTone } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts"; import { MEDIA_KIND } from "~/components/library/labels.ts";
@@ -63,3 +71,157 @@ export const gbToBytes = (s: string): number | undefined => {
const n = Number(s); const n = Number(s);
return s.trim() && Number.isFinite(n) && n >= 0 ? Math.round(n * GB) : undefined; return s.trim() && Number.isFinite(n) && n >= 0 ? Math.round(n * GB) : undefined;
}; };
// ── 资源行(SEARCH-5:学 MP 的规格标签、促销角标、按作品分组;加上本工具的规则判定)──
export type SpecDim = "episode" | "resolution" | "source" | "codec" | "hdr" | "audio" | "lang" | "group";
export interface ReleaseSpecTag {
key: string;
dim: SpecDim;
label: string;
/** 悬停提示:「分辨率:1080p」 */
title: string;
}
export const SPEC_DIM_LABEL: Record<SpecDim, string> = {
episode: "季集",
resolution: "分辨率",
source: "来源",
codec: "编码",
hdr: "HDR",
audio: "音频",
lang: "语言",
group: "制作组",
};
const SUB_LABEL: Record<string, string> = {
zh: "中字",
"zh-Hans": "简中",
"zh-Hant": "繁中",
en: "英字",
ja: "日字",
ko: "韩字",
};
const DUB_LABEL: Record<string, string> = { cmn: "国语", yue: "粤语", en: "英语配音" };
const pad2 = (n: number) => String(n).padStart(2, "0");
/**
* 规格标签,固定顺序:季集 → 分辨率 → 来源 → 编码 → HDR → 音频 → 配音 / 字幕语言 → 制作组。
* 都来自标题解析;同名去重,不猜。
*/
export function releaseSpecTags(p: SearchReleaseParsed): ReleaseSpecTag[] {
const out: ReleaseSpecTag[] = [];
const seen = new Set<string>();
const add = (dim: SpecDim, label: string | null | undefined) => {
const l = label?.trim();
if (!l || seen.has(`${dim}:${l.toLowerCase()}`)) return;
seen.add(`${dim}:${l.toLowerCase()}`);
out.push({ key: `${dim}:${l}`, dim, label: l, title: `${SPEC_DIM_LABEL[dim]}:${l}` });
};
if (p.season !== null || p.episodes) {
const s =
p.season !== null
? `S${pad2(p.season)}${p.seasonEnd && p.seasonEnd !== p.season ? `-S${pad2(p.seasonEnd)}` : ""}`
: "";
const e = p.episodes
? `E${pad2(p.episodes.start)}${p.episodes.end !== p.episodes.start ? `-E${pad2(p.episodes.end)}` : ""}`
: "";
add("episode", `${s}${e}`);
}
add("resolution", p.resolution);
add("source", p.source);
add("codec", p.videoCodec);
for (const h of p.hdr) add("hdr", h);
for (const a of p.audio ?? []) add("audio", a);
for (const l of p.audioLangs ?? []) add("lang", DUB_LABEL[l] ?? l);
for (const l of p.subtitles ?? []) add("lang", SUB_LABEL[l] ?? l);
add("group", p.releaseGroup);
return out;
}
/** 促销标签:免费(含 2x 免费)属状态,用 success;其余折扣中性 */
export function promoBadge(
r: Pick<SearchRelease, "promotion" | "free">,
): { label: string; tone: "success" | "neutral" } | null {
const label = promoLabel(r.promotion.type);
if (!label) return null;
return { label, tone: r.free ? "success" : "neutral" };
}
/** H&R:明确有 → warning;未知 → 中性「H&R 未知」;明确没有 → 不显示 */
export function hrBadge(r: Pick<SearchRelease, "hr">): { label: string; tone: "warning" | "neutral" } | null {
if (r.hr === true) return { label: "H&R", tone: "warning" };
if (r.hr === "unknown") return { label: "H&R 未知", tone: "neutral" };
return null;
}
export interface VerdictView {
label: string;
tone: "success" | "neutral";
/** 悬停提示:档位名、不满足的逐条说明 */
title: string;
}
/** 规则判定 → 文案:「命中第 1 档」「满足收录条件」「不满足:分辨率、来源」「无法判断:分辨率」;多个收录版本时带名字 */
export function verdictView(v: SearchRuleVerdict): VerdictView {
const dims = (xs: string[]) => (xs.length > 3 ? `${xs.slice(0, 3).join("、")} 等 ${xs.length} 项` : xs.join("、"));
const text =
v.outcome === "hit"
? v.tier !== null
? `命中第 ${v.tier} 档`
: "满足收录条件"
: v.outcome === "miss"
? `不满足:${dims(v.failed) || "收录条件"}`
: `无法判断:${dims(v.failed) || "缺少字段"}`;
const label = v.slotCount > 1 ? `「${v.slotName}」${text}` : text;
const title = [v.slotCount > 1 ? `收录版本「${v.slotName}」` : null, v.tierName, ...v.details]
.filter(Boolean)
.join("\n");
return { label, tone: v.outcome === "hit" ? "success" : "neutral", title };
}
/** 与库里版本比较:「比库里版本高 1 档」「比库里版本好」「与库里版本同档」「不如库里版本」 */
export function vsLibraryLabel(v: SearchRuleVerdict): string | null {
const c = v.vsLibrary;
if (!c) return null;
if (c.cmp === "higher") return c.steps ? `比库里版本高 ${c.steps} 档` : "比库里版本好";
if (c.cmp === "same") return "与库里版本同档";
return "不如库里版本";
}
export interface WorkGroup {
/** 作品 ID;未关联作品的一组为 null */
key: string | null;
entity: SearchRelease["entity"];
entityMatch: SearchRelease["entityMatch"];
inventory: SearchRelease["inventory"];
items: SearchRelease[];
}
/**
* 按作品分组(保持结果顺序,组按首次出现排;未关联作品的放最后)。
* 只涉及一部作品(或全都未关联)时 grouped = false:作品名只在列表上方出现一次,行里不重复。
*/
export function groupByWork(rows: readonly SearchRelease[]): { grouped: boolean; groups: WorkGroup[] } {
const map = new Map<string, WorkGroup>();
let loose: WorkGroup | null = null;
for (const r of rows) {
if (!r.entity) {
loose ??= { key: null, entity: null, entityMatch: null, inventory: null, items: [] };
loose.items.push(r);
continue;
}
let g = map.get(r.entity.id);
if (!g) {
g = { key: r.entity.id, entity: r.entity, entityMatch: r.entityMatch, inventory: r.inventory, items: [] };
map.set(r.entity.id, g);
}
// 同一作品里有已识别的,组头就不标「未确认」
if (r.entityMatch === "resolved") g.entityMatch = "resolved";
g.inventory ??= r.inventory;
g.items.push(r);
}
const groups = [...map.values(), ...(loose ? [loose] : [])];
return { grouped: groups.length > 1, groups };
}