import type { ReleaseSearchPage, SearchRelease } from "@umt/contracts"; import { ArrowDown, ArrowDownWideNarrow, ArrowUp, ArrowUpNarrowWide, Bot, ChevronDown, Download, ExternalLink, PackageSearch, RefreshCw, TriangleAlert, } 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 { relTime } 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, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts"; // 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;快速筛选(SEARCH-3:分辨率/编码/版本/站点/ // 促销/HDR/H&R/制作组/季集,多选带计数),同一行右侧是大小区间与排序(POLISH-1,手机收进抽屉);游标翻页; // 行操作同站点工作台(库存叠加、下载、委托智能体)。耗时、查询词放进「技术信息」。 /** 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 ? ( ) : (
    {paged.items?.map((r) => ( setSelected(r)} onDownload={() => setDl(r)} /> ))}
)} {rows.length > 0 || res.hasNextPage ? : 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}
); } /** 部分站点失败 / 跳过:列出站点,其余结果照常 */ 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 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(" · "); 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} ) : ( 未关联本地作品 )} {r.sourceReleaseId ? ( ) : null}
  • ); }