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:
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">
|
||||
|
||||
Reference in new issue
Block a user