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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-06 12:04:37 +08:00

228 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
LocalMediaHit,
MediaKind,
SearchInventoryState,
SearchRelease,
SearchReleaseParsed,
SearchRuleVerdict,
SearchSiteStatus,
} from "@umt/contracts";
import { Compass, type LucideIcon, Zap } from "lucide-react";
import type { StatusTone } from "~/components/console/status-badge.tsx";
import { MEDIA_KIND } from "~/components/library/labels.ts";
// 搜索结果的展示口径
export const mediaKindLabel = (k: MediaKind) => MEDIA_KIND[k] ?? k;
/** 本地作品的一行摘要:订阅 / 版本 / 下载中 / 缺口 */
export function slotSummary(h: LocalMediaHit): string {
const c = h.slotCounts;
const parts: string[] = [];
if (h.subscribed) parts.push("已订阅");
if (h.versionCount) parts.push(`${h.versionCount} 个版本`);
if (h.downloadingCount) parts.push(`${h.downloadingCount} 个下载中`);
const gaps = c.empty + c.attention + c.upgrade_pending;
if (gaps) parts.push(`${gaps} 个收录版本未满足`);
return parts.join(" · ") || "未订阅";
}
export const actionIcon = (kind: "page" | "action"): LucideIcon => (kind === "page" ? Compass : Zap);
export const INVENTORY_STATE: Record<SearchInventoryState, { label: string; tone: StatusTone }> = {
downloading: { label: "下载中", tone: "warning" },
gap: { label: "有缺口", tone: "warning" },
owned: { label: "已有版本", tone: "success" },
subscribed: { label: "已订阅", tone: "neutral" },
none: { label: "本地无版本", tone: "neutral" },
};
export const SITE_STATUS: Record<SearchSiteStatus, string> = {
ok: "正常",
failed: "失败",
timeout: "超时",
skipped: "跳过",
};
export const SORT_OPTIONS = [
{ value: "default", label: "默认" },
{ value: "seeders", label: "做种数" },
{ value: "size", label: "大小" },
{ value: "uploadedAt", label: "发布时间" },
{ value: "site", label: "站点" },
] as const;
const PROMO: Record<string, string> = {
free: "免费",
"2xfree": "2x 免费",
"50%": "50%",
"2x50%": "2x 50%",
"30%": "30%",
"2x": "2x 上传",
};
/** 促销文案;none/unknown 返回 null */
export function promoLabel(type: string): string | null {
if (!type || type === "none" || type === "unknown") return null;
return PROMO[type] ?? type;
}
const GB = 1024 ** 3;
export const gbToBytes = (s: string): number | undefined => {
const n = Number(s);
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 };
}