diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx
index dbb26476..a8c4dcf7 100644
--- a/apps/web/app/components/search/release-results.tsx
+++ b/apps/web/app/components/search/release-results.tsx
@@ -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"
/>
) : (
-
- {paged.items?.map((r) => (
- setSelected(r)} onDownload={() => setDl(r)} />
- ))}
-
+
)}
{rows.length > 0 || res.hasNextPage ? : null}
@@ -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 (
-
-
-
-
- {promo ? {promo} : null}
- {r.hr === true ? H&R : null}
- {r.hr === "unknown" ? H&R 未知 : null}
-
-
- {r.subtitle ? {r.subtitle}
: null}
-
-
{r.siteName}
- {r.category.name ?
{r.category.name} : null}
-
{r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)}
-
-
- {r.seeders ?? "—"}
-
- {r.leechers ?? "—"}
-
-
发布 {relTime(r.uploadedAt)}
- {tech ?
{tech} : null}
-
-
-
- {r.entity ? (
- <>
-
- {r.entity.title}
- {r.entity.year ? ` (${r.entity.year})` : ""}
-
- {r.entityMatch === "alias" ? 未确认 : null}
- {inv ? {inv.label} : null}
- >
- ) : (
- 未关联本地作品
+
+ {g.entity ? (
+ <>
+
+ {g.entity.title}
+ {g.entity.year ? ` (${g.entity.year})` : ""}
+
+ {g.entityMatch === "alias" ? 未确认 : null}
+ {inv ? {inv.label} : null}
+ >
+ ) : (
+ 未关联本地作品
+ )}
+ {count} 条
+
+ );
+}
+
+/** 按作品分组的结果列表;只涉及一部作品时作品名只在上方出现一次 */
+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) => (
+ onOpen(r)} onDownload={() => onDownload(r)} />
+ );
+ if (!grouped)
+ return (
+ <>
+ {single ? : null}
+
+ >
+ );
+ return (
+
+ {groups.map((g) => (
+
+ ))}
+
+ );
+}
+
+/** 促销放在标题前:免费用 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" ? (
+
+
+ {text}
+
+
+ ) : (
+
+ {text}
+
+ );
+}
+
+/** 规格标签:中性小标签,固定顺序;制作组带图标区分 */
+function SpecTagList({ r }: { r: SearchRelease }) {
+ const tags = releaseSpecTags(r.parsed);
+ if (!tags.length) return null;
+ return (
+
+ {tags.map((t) => (
+
+ {t.dim === "group" ? : null}
+ {t.label}
+ ))}
+
+ );
+}
+
+/** 规则判定 + 与库里版本比较 */
+function VerdictLine({ r }: { r: SearchRelease }) {
+ const v = r.ruleVerdict;
+ if (!v) return null;
+ const view = verdictView(v);
+ const vs = vsLibraryLabel(v);
+ return (
+
+
+ {view.label}
+
+ {vs ? {vs} : null}
+
+ );
+}
+
+/**
+ * 资源行:左边 = 促销 + 标题 / 规格标签 / 规则判定;右边 = 站点、大小、做种·下载、发布时间定宽对齐,
+ * 下面是操作。宽屏像表格一样上下对齐,窄屏右栏折到下面。
+ */
+function ReleaseRow({ r, onOpen, onDownload }: { r: SearchRelease; onOpen: () => void; onDownload: () => void }) {
+ const hr = hrBadge(r);
+ const seedless = r.seeders === 0 || r.seeders === null;
+ return (
+
+
+
+
+
+ {hr ? (
+
+ {hr.label}
+
+ ) : null}
+
+ {r.subtitle ?
{r.subtitle}
: null}
+
+
+
+
+
+
+ {r.siteName}
+
+
{r.sizeBytes === null ? "—" : formatBytes(r.sizeBytes, 1)}
+
+
+
+ {r.seeders ?? "—"}
+
+
+ {r.leechers ?? "—"}
+
+
+ {relTime(r.uploadedAt)}
+
+
{r.sourceReleaseId ? (