import type { ReleaseSearchPage, SearchRelease } from "@umt/contracts"; import { ArrowDown, ArrowDownWideNarrow, ArrowUp, ArrowUpNarrowWide, Bot, ChevronDown, Download, ExternalLink, PackageSearch, RefreshCw, TriangleAlert, UsersRound, } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { Link } from "react-router"; import { toast } from "sonner"; import { type ReleaseSearchFilters, useRefreshReleaseSearch, useReleaseSearch } from "~/api/search.ts"; import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { TechInfo } from "~/components/console/tech-info.tsx"; import { useListPagination } from "~/components/console/use-list-pagination.ts"; import { Button } from "~/components/ui/button.tsx"; 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 { 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"; import { formatQuickFilter, mergeLegacyFilters, type QuickSelection, selectedCount, useQuickFilters, } from "./quick-filter-state.ts"; import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; 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)。 // 按作品搜时,服务端判定不属于这部作品的资源(对照 MP match_torrent)不参与筛选与分面, // 在列表最后折叠为「另有 N 条可能不是这部作品」,展开后仍可下载。 /** URL 里的资源筛选(字符串形态,空串 = 不筛) */ export type ReleaseFilterState = { sites: string; resolution: string; source: string; minGb: string; maxGb: string; free: string; hr: string; sort: string; order: string; }; export const RELEASE_FILTER_KEYS = [ "sites", "resolution", "source", "minGb", "maxGb", "free", "hr", "sort", "order", ] as const satisfies readonly (keyof ReleaseFilterState)[]; /** 搜什么:关键词,或按作品(服务端生成查询词) */ export type ReleaseTarget = { q?: string; entityId?: string; tmdb?: string }; /** URL 参数 work → 搜索目标:local: / tmdb:: / q:<关键词> */ export function parseWorkRef(ref: string): ReleaseTarget | null { if (ref.startsWith("local:")) return { entityId: ref.slice(6) }; const m = /^tmdb:(movie|tv):(\d+)$/.exec(ref); if (m) return { tmdb: `${m[1]}:${m[2]}` }; if (ref.startsWith("q:") && ref.slice(2).trim()) return { q: ref.slice(2).trim() }; return null; } export function toApiFilters( target: ReleaseTarget, f: ReleaseFilterState, siteIds = "", quick: QuickSelection = {}, ): ReleaseSearchFilters { const sort = SORT_OPTIONS.some((o) => o.value === f.sort) ? (f.sort as ReleaseSearchFilters["sort"]) : undefined; return { ...target, sites: siteIds || undefined, siteFilter: f.sites || undefined, resolution: f.resolution || undefined, source: f.source || undefined, qf: formatQuickFilter(quick), minSize: gbToBytes(f.minGb), maxSize: gbToBytes(f.maxGb), // free / hr 是旧筛选行的 URL 参数,进页面时并进快速筛选(mergeLegacyFilters),不再单独发 sort, order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined, }; } export function ReleaseResults({ target, siteIds, filters, setFilter, onChangeSites, }: { target: ReleaseTarget; /** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */ siteIds: string; onChangeSites?: () => void; filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; }) { const [quick, setQuick] = useQuickFilters("search"); const api = useMemo(() => toApiFilters(target, filters, siteIds, quick), [target, filters, siteIds, quick]); const res = useReleaseSearch(api); const refresh = useRefreshReleaseSearch(); const pages = res.data?.pages ?? []; const first = pages[0]; const rows = useMemo(() => pages.flatMap((p) => p.items), [pages]); const paged = useListPagination(rows, { hasMore: res.hasNextPage, onLoadMore: () => res.fetchNextPage(), loadingMore: res.isFetchingNextPage, error: res.error, resetKey: JSON.stringify(api), }); const [dl, setDl] = useState(null); const [selected, setSelected] = useState(null); const filtered = selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]); // 旧链接里的免费 / H&R 参数并进快速筛选 // biome-ignore lint/correctness/useExhaustiveDependencies: 只在旧参数出现时迁移一次 useEffect(() => { if (!filters.free && !filters.hr) return; setQuick(mergeLegacyFilters(quick, { free: filters.free, hr: filters.hr })); setFilter("free", ""); setFilter("hr", ""); }, [filters.free, filters.hr]); /** 唯一的「清除」:分面、大小区间都清掉,排序保留 */ const clearAll = () => { setQuick({}); for (const k of RELEASE_FILTER_KEYS) if (k !== "sort" && k !== "order") setFilter(k, ""); }; const sizeTag = sizeLabel(filters.minGb, filters.maxGb); return (

资源 {first ? ( {first.total === first.unfilteredTotal ? `${first.total} 条` : `${first.total} / ${first.unfilteredTotal} 条`} ) : null}

{first ? (

{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "实时结果"} · 上次抓取{" "} {relTime(first.fetchedAt)}

) : null}
{onChangeSites ? ( ) : null}
{first ? : null} {first ? ( { setFilter("minGb", ""); setFilter("maxGb", ""); }, }, ] : [] } aside={{ label: "大小与排序", active: !!sizeTag || !!filters.sort || filters.order === "asc", content: (layout) => , }} /> ) : null}
{res.isPending ? (

查询各站点中,最长约 15 秒…

) : res.isError && !rows.length ? ( res.refetch()} /> ) : rows.length === 0 ? ( ) : ( )} {rows.length > 0 || res.hasNextPage ? : null} {first?.unmatchedTotal ? ( ) : null}
{first ? ( `「${x}」`).join("") : null }, { label: "搜索词", value: first.q || null }, ...first.sites.map((x) => ({ label: x.siteName, value: `${x.count} 条 · ${x.tookMs} ms` })), ]} /> ) : null} {dl ? !o && setDl(null)} /> : null} {selected?.sourceReleaseId ? ( setSelected(null)} /> ) : null}
); } /** 「另有 N 条可能不是这部作品」:默认收起,展开后与上面的资源行一样可查看、下载 */ export function UnmatchedReleases({ rows, total, onOpen, onDownload, defaultOpen = false, }: { rows: SearchRelease[]; total: number; onOpen: (r: SearchRelease) => void; onDownload: (r: SearchRelease) => void; defaultOpen?: boolean; }) { const [open, setOpen] = useState(defaultOpen); if (!total) return null; return (
{open ? (
) : null}
); } /** 部分站点失败 / 跳过:列出站点,其余结果照常 */ function CoverageNotice({ page }: { page: ReleaseSearchPage }) { const [open, setOpen] = useState(false); const skipped = page.sites.filter((s) => s.status === "skipped"); const ok = page.sites.filter((s) => s.status === "ok").length; return (
{page.partial ? (

{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。

    {page.failedSites.map((s) => (
  • {s.siteName}:{SITE_STATUS[s.status]} {s.error ? `(${s.error})` : ""}
  • ))}
) : null} {skipped.length ? (
{open ? (
    {skipped.map((s) => (
  • {s.siteName}:{s.error ?? "不支持搜索"}
  • ))}
) : null}
) : null}
); } /** 大小区间的标签文字:「5–20 GB」「≥ 5 GB」「≤ 20 GB」 */ export function sizeLabel(min: string, max: string): string | null { if (min && max) return `${min}–${max} GB`; if (min) return `≥ ${min} GB`; if (max) return `≤ ${max} GB`; return null; } /** 大小区间 + 排序:桌面放在快速筛选同一行右侧,手机在抽屉里竖排 */ function SizeSortControls({ filters, setFilter, layout, }: { filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; layout: "inline" | "sheet"; }) { const [minGb, setMinGb] = useState(filters.minGb); const [maxGb, setMaxGb] = useState(filters.maxGb); useEffect(() => setMinGb(filters.minGb), [filters.minGb]); useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]); const desc = filters.order !== "asc"; const sheet = layout === "sheet"; const commit = (k: "minGb" | "maxGb", v: string) => setFilter(k, v.trim()); const size = (
大小 setMinGb(e.target.value)} onBlur={() => commit("minGb", minGb)} onKeyDown={(e) => e.key === "Enter" && commit("minGb", minGb)} placeholder="最小" aria-label="最小大小(GB)" className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")} /> – setMaxGb(e.target.value)} onBlur={() => commit("maxGb", maxGb)} onKeyDown={(e) => e.key === "Enter" && commit("maxGb", maxGb)} placeholder="最大" aria-label="最大大小(GB)" className={cn("h-8", sheet ? "min-w-0 flex-1" : "w-16")} /> GB
); const sort = (
{sheet ? 排序 : null}
); return sheet ? ( <> {size} {sort} ) : ( <> {size} {sort} ); } /** 作品组头:作品名 + 年份 + 未确认 + 库存状态 + 条数 */ function WorkHeader({ g, count }: { g: WorkGroup; count: number }) { const inv = g.inventory ? INVENTORY_STATE[g.inventory.state] : null; return (
{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}
    {rows.map(row)}
); return (
{groups.map((g) => (
    {g.items.map(row)}
))}
); } /** 促销放在标题前:免费用 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 ? ( ) : null}
  • ); }