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
+555 -59

No files matched your search

@@ -11,6 +11,7 @@ import {
PackageSearch,
RefreshCw,
TriangleAlert,
UsersRound,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router";
@@ -25,7 +26,7 @@ import { Input } from "~/components/ui/input.tsx";
import { Pagination } from "~/components/ui/pagination.tsx";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.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 { cn, formatBytes } from "~/lib/utils.ts";
import { QuickFilterBar } from "./quick-filter-bar.tsx";
@@ -37,11 +38,25 @@ import {
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";
import {
gbToBytes,
groupByWork,
hrBadge,
INVENTORY_STATE,
promoBadge,
releaseSpecTags,
SITE_STATUS,
SORT_OPTIONS,
verdictView,
vsLibraryLabel,
type WorkGroup,
} from "./search-labels.ts";
// 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/
// 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页;
// 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。
// SEARCH-5(参考 MP 资源卡片):促销放标题前、规格拆成中性标签、关键数字定宽对齐、按作品分组,
// 每行附规则判定(命中第几档 / 不满足什么 / 与库里版本比较,服务端 ruleVerdict)。
/** URL 里的资源筛选(字符串形态,空串 = 不筛) */
export type ReleaseFilterState = {
@@ -237,11 +252,7 @@ export function ReleaseResults({
className="py-12"
/>
) : (
<ul className="divide-y divide-border/60">
{paged.items?.map((r) => (
<ReleaseRow key={r.key} r={r} onOpen={() => setSelected(r)} onDownload={() => setDl(r)} />
))}
</ul>
<ReleaseList rows={paged.items ?? []} onOpen={setSelected} onDownload={setDl} />
)}
{rows.length > 0 || res.hasNextPage ? <Pagination {...paged.pagination} /> : null}
</div>
@@ -420,60 +431,180 @@ function SizeSortControls({
);
}
function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
const promo = promoLabel(r.promotion.type);
const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null;
const p = r.parsed;
const tech = [p.resolution, p.source, p.videoCodec, ...p.hdr, p.releaseGroup].filter(Boolean).join(" · ");
/** 作品组头:作品名 + 年份 + 未确认 + 库存状态 + 条数 */
function WorkHeader({ g, count }: { g: WorkGroup; count: number }) {
const inv = g.inventory ? INVENTORY_STATE[g.inventory.state] : null;
return (
<li className="px-4 py-3">
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
<button
type="button"
onClick={onOpen}
disabled={!r.sourceReleaseId}
className="min-w-0 flex-1 cursor-pointer text-left text-sm font-medium break-all text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:cursor-default disabled:no-underline"
>
{r.title}
</button>
<span className="flex flex-wrap gap-1">
{promo ? <StatusBadge tone={r.free ? "success" : "neutral"}>{promo}</StatusBadge> : null}
{r.hr === true ? <StatusBadge tone="warning">H&R</StatusBadge> : null}
{r.hr === "unknown" ? <StatusBadge>H&R 未知</StatusBadge> : null}
</span>
</div>
{r.subtitle ? <p className="mt-0.5 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">
<span className="font-medium text-foreground">{r.siteName}</span>
{r.category.name ? <span>{r.category.name}</span> : null}
<span>{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}</span>
<span className="inline-flex items-center gap-0.5" title="做种 / 下载">
<ArrowUp className="h-3 w-3" />
{r.seeders ?? "—"}
<ArrowDown className="ml-1 h-3 w-3" />
{r.leechers ?? "—"}
</span>
<span title="站点发布时间">发布 {relTime(r.uploadedAt)}</span>
{tech ? <span className="break-all">{tech}</span> : null}
</div>
<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}
{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>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 bg-foreground/[0.03] px-4 py-2">
{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
type="button"
onClick={onOpen}
disabled={!r.sourceReleaseId}
className="min-w-0 flex-1 cursor-pointer text-left text-sm font-medium break-all text-foreground underline-offset-4 hover:underline focus-visible:underline disabled:cursor-default disabled:no-underline"
>
{r.title}
</button>
{hr ? (
<span className="shrink-0">
<StatusBadge tone={hr.tone}>{hr.label}</StatusBadge>
</span>
) : null}
</div>
{r.subtitle ? <p className="line-clamp-1 text-caption text-muted-foreground">{r.subtitle}</p> : null}
<SpecTagList r={r} />
<VerdictLine r={r} />
</div>
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-meta text-muted-foreground tabular-nums lg:flex-nowrap">
<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 ?? "—"}
</span>
<ArrowDown aria-label="下载" className="ml-1.5 h-3 w-3" />
{r.leechers ?? "—"}
</span>
<span
className="lg:w-20 lg:text-right"
title={r.uploadedAt ? `发布于 ${absTime(r.uploadedAt)}` : "发布时间未知"}
>
{relTime(r.uploadedAt)}
</span>
</div>
<span className="flex flex-wrap items-center gap-1">
{r.sourceReleaseId ? (
<Button asChild variant="ghost" size="sm">