import type { FacetCount, 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 { FilterSelect } from "~/components/console/filter-bar.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; import { ChipToggleGroup } from "~/components/library/parts.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Input } from "~/components/ui/input.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select.tsx"; import { relTime } from "~/components/workbench/release-fields.ts"; import { errorMessage } from "~/lib/api.ts"; import { cn, formatBytes } from "~/lib/utils.ts"; import { delegateToAgent, SearchDownloadDialog } from "./search-download-dialog.tsx"; import { gbToBytes, INVENTORY_STATE, promoLabel, SITE_STATUS, SORT_OPTIONS } from "./search-labels.ts"; // 资源段(SEARCH-2 第二阶段):按作品或关键词、在选中的站点上搜;筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标; // 行操作同站点工作台(库存叠加、下载、委托 Agent)。 /** 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 = ""): ReleaseSearchFilters { const bool = (v: string) => (v === "true" || v === "false" ? v : undefined); 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, minSize: gbToBytes(f.minGb), maxSize: gbToBytes(f.maxGb), free: bool(f.free), hr: bool(f.hr), sort, order: f.order === "asc" ? "asc" : f.order === "desc" ? "desc" : undefined, }; } const csvList = (s: string) => (s ? s.split(",").filter(Boolean) : []); export function ReleaseResults({ target, siteIds, filters, setFilter, resetFilters, onChangeSites, }: { target: ReleaseTarget; /** 选中的站点(逗号分隔);空 = 服务端按默认站点设置 */ siteIds: string; onChangeSites?: () => void; filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; resetFilters: () => void; }) { const api = useMemo(() => toApiFilters(target, filters, siteIds), [target, filters, siteIds]); 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 [dl, setDl] = useState(null); const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]); return (

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

{first ? (

{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "} {relTime(first.fetchedAt)} · {first.tookMs} ms {first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}

) : null}
{onChangeSites ? ( ) : null}
{first ? : null} {first ? ( ) : null}
{res.isPending ? (

正在查询各站点,最慢约 15 秒…

) : res.isError && !rows.length ? ( res.refetch()} /> ) : rows.length === 0 ? ( ) : (
    {rows.map((r) => ( setDl(r)} /> ))}
)} {rows.length > 0 && res.hasNextPage ? (
) : null}
{dl ? !o && setDl(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}
); } const facetOpts = (items: FacetCount[]) => items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` })); function Filters({ page, filters, setFilter, onReset, }: { page: ReleaseSearchPage; filters: ReleaseFilterState; setFilter: (k: keyof ReleaseFilterState, v: string) => void; onReset?: () => void; }) { 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 facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [ { key: "sites", label: "站点", items: page.facets.sites }, { key: "resolution", label: "分辨率", items: page.facets.resolutions }, { key: "source", label: "来源", items: page.facets.sources }, ]; const desc = filters.order !== "asc"; return (
{facets .filter((f) => f.items.length > 0) .map((f) => (
{f.label}
setFilter(f.key, v.join(","))} />
))}
大小 setMinGb(e.target.value)} onBlur={() => setFilter("minGb", minGb.trim())} onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())} placeholder="最小" aria-label="最小大小(GB)" className="h-8 w-20" /> – setMaxGb(e.target.value)} onBlur={() => setFilter("maxGb", maxGb.trim())} onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())} placeholder="最大" aria-label="最大大小(GB)" className="h-8 w-20" /> GB
setFilter("free", v)} options={[ { value: "true", label: "只看免费" }, { value: "false", label: "排除免费" }, ]} /> setFilter("hr", v)} options={[ { value: "true", label: "只看 H&R" }, { value: "false", label: "排除 H&R" }, ]} />
{onReset ? ( ) : null}
); } function ReleaseRow({ r, onDownload }: { r: SearchRelease; 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 (
  • {r.title} {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}
  • ); }