Co-Authored-By: Claude Fable 5.1 <[email protected]> # Conflicts: # apps/server/src/modules/acquisition-organize/services/organize.ts # apps/web/app/routes/console/sites.$siteId.tsx
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
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 { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||
import { StatusBadge } from "~/components/console/status-badge.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 { QuickFilterBar } from "./quick-filter-bar.tsx";
|
||
import { formatQuickFilter, 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、排序、游标;
|
||
// 行操作同站点工作台(库存叠加、下载、委托 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:<id> / tmdb:<type>:<id> / 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 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,
|
||
qf: formatQuickFilter(quick),
|
||
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,
|
||
};
|
||
}
|
||
|
||
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 [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 [dl, setDl] = useState<SearchRelease | null>(null);
|
||
const filtered =
|
||
selectedCount(quick) > 0 || RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
|
||
const resetAll = () => {
|
||
setQuick({});
|
||
resetFilters();
|
||
};
|
||
|
||
return (
|
||
<section aria-labelledby="search-releases" className="min-w-0">
|
||
<div className="mb-3 flex flex-wrap items-end justify-between gap-2">
|
||
<div className="min-w-0">
|
||
<h2 id="search-releases" className="text-body font-semibold text-foreground">
|
||
资源
|
||
{first ? (
|
||
<span className="ml-2 text-caption font-normal text-muted-foreground tabular-nums">
|
||
{first.total === first.unfilteredTotal
|
||
? `${first.total} 条`
|
||
: `${first.total} / ${first.unfilteredTotal} 条`}
|
||
</span>
|
||
) : null}
|
||
</h2>
|
||
{first ? (
|
||
<p className="mt-0.5 text-meta break-words text-muted-foreground tabular-nums">
|
||
{first.sites.length} 个站点 · {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于{" "}
|
||
{relTime(first.fetchedAt)} · {first.tookMs} ms
|
||
{first.queries?.length ? ` · 查询词 ${first.queries.map((x) => `「${x}」`).join("")}` : ""}
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="flex flex-wrap gap-1">
|
||
{onChangeSites ? (
|
||
<Button variant="ghost" size="sm" onClick={onChangeSites}>
|
||
换站点
|
||
</Button>
|
||
) : null}
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
disabled={refresh.isPending || res.isFetching}
|
||
onClick={() => refresh.mutate(api, { onError: (e) => toast.error(errorMessage(e, "刷新失败")) })}
|
||
>
|
||
<RefreshCw className={cn(refresh.isPending && "animate-spin")} />
|
||
{refresh.isPending ? "查询中…" : "刷新"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{first ? <CoverageNotice page={first} /> : null}
|
||
|
||
{first ? <QuickFilterBar facets={first.facets} value={quick} onChange={setQuick} /> : null}
|
||
{first ? <Filters filters={filters} setFilter={setFilter} onReset={filtered ? resetAll : undefined} /> : null}
|
||
|
||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||
{res.isPending ? (
|
||
<div className="p-4">
|
||
<p className="mb-3 text-meta text-muted-foreground">正在查询各站点,最慢约 15 秒…</p>
|
||
<LoadingState rows={4} />
|
||
</div>
|
||
) : res.isError && !rows.length ? (
|
||
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
|
||
) : rows.length === 0 ? (
|
||
<EmptyState
|
||
plain
|
||
icon={PackageSearch}
|
||
title={filtered ? "没有符合筛选条件的资源" : "所选站点都没有找到资源"}
|
||
description={filtered ? "放宽筛选条件再看。" : "换站点或换关键词再搜,或到站点页检查站点状态。"}
|
||
className="py-12"
|
||
/>
|
||
) : (
|
||
<ul className="divide-y divide-border/60">
|
||
{rows.map((r) => (
|
||
<ReleaseRow key={r.key} r={r} onDownload={() => setDl(r)} />
|
||
))}
|
||
</ul>
|
||
)}
|
||
{rows.length > 0 && res.hasNextPage ? (
|
||
<div className="flex justify-center border-t border-border/60 px-4 py-3">
|
||
<Button variant="outline" size="sm" onClick={() => res.fetchNextPage()} disabled={res.isFetchingNextPage}>
|
||
{res.isFetchingNextPage ? "加载中…" : "加载更多"}
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
|
||
{dl ? <SearchDownloadDialog release={dl} open={!!dl} onOpenChange={(o) => !o && setDl(null)} /> : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/** 部分站点失败 / 跳过:列出站点,其余结果照常 */
|
||
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 (
|
||
<div className="mb-3 space-y-2">
|
||
{page.partial ? (
|
||
<div className="flex gap-2 rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-foreground">
|
||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-warning" />
|
||
<div className="min-w-0">
|
||
<p>
|
||
{page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。
|
||
</p>
|
||
<ul className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-meta text-muted-foreground">
|
||
{page.failedSites.map((s) => (
|
||
<li key={s.siteId} className="break-all" title={s.error ?? undefined}>
|
||
{s.siteName}:{SITE_STATUS[s.status]}
|
||
{s.error ? `(${s.error})` : ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{skipped.length ? (
|
||
<div className="text-meta text-muted-foreground">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
className="inline-flex cursor-pointer items-center gap-1 hover:text-foreground"
|
||
>
|
||
<ChevronDown className={cn("h-3 w-3 transition-transform duration-micro", open && "rotate-180")} />
|
||
{skipped.length} 个站点未参与搜索
|
||
</button>
|
||
{open ? (
|
||
<ul className="mt-1 space-y-0.5 pl-4">
|
||
{skipped.map((s) => (
|
||
<li key={s.siteId} className="break-all">
|
||
{s.siteName}:{s.error ?? "站点不支持搜索"}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Filters({
|
||
filters,
|
||
setFilter,
|
||
onReset,
|
||
}: {
|
||
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 desc = filters.order !== "asc";
|
||
|
||
return (
|
||
<div className="mb-3 space-y-2.5">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-meta text-muted-foreground">大小</span>
|
||
<Input
|
||
inputMode="decimal"
|
||
value={minGb}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<span className="text-meta text-muted-foreground">–</span>
|
||
<Input
|
||
inputMode="decimal"
|
||
value={maxGb}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<span className="text-meta text-muted-foreground">GB</span>
|
||
</div>
|
||
<FilterSelect
|
||
label="免费"
|
||
value={filters.free}
|
||
onChange={(v) => setFilter("free", v)}
|
||
options={[
|
||
{ value: "true", label: "只看免费" },
|
||
{ value: "false", label: "排除免费" },
|
||
]}
|
||
/>
|
||
<FilterSelect
|
||
label="H&R"
|
||
value={filters.hr}
|
||
onChange={(v) => setFilter("hr", v)}
|
||
options={[
|
||
{ value: "true", label: "只看 H&R" },
|
||
{ value: "false", label: "排除 H&R" },
|
||
]}
|
||
/>
|
||
<div className="flex items-center gap-1">
|
||
<Select value={filters.sort || "default"} onValueChange={(v) => setFilter("sort", v === "default" ? "" : v)}>
|
||
<SelectTrigger size="sm" aria-label="排序" className="min-w-32">
|
||
<span className="text-muted-foreground">排序</span>
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent position="popper">
|
||
{SORT_OPTIONS.map((o) => (
|
||
<SelectItem key={o.value} value={o.value}>
|
||
{o.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<Button
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8"
|
||
aria-label={desc ? "降序,点击改为升序" : "升序,点击改为降序"}
|
||
title={desc ? "降序" : "升序"}
|
||
onClick={() => setFilter("order", desc ? "asc" : "")}
|
||
>
|
||
{desc ? <ArrowDownWideNarrow className="h-4 w-4" /> : <ArrowUpNarrowWide className="h-4 w-4" />}
|
||
</Button>
|
||
</div>
|
||
{onReset ? (
|
||
<Button variant="ghost" size="sm" onClick={onReset}>
|
||
清除筛选
|
||
</Button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<li className="px-4 py-3">
|
||
<div className="flex min-w-0 flex-wrap items-start gap-x-2 gap-y-1">
|
||
<span className="min-w-0 flex-1 text-sm font-medium break-all text-foreground">{r.title}</span>
|
||
<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>
|
||
)}
|
||
</span>
|
||
<span className="flex flex-wrap items-center gap-1">
|
||
{r.sourceReleaseId ? (
|
||
<Button asChild variant="ghost" size="sm">
|
||
<Link to={`/console/sites/${r.siteId}`} title="在站点工作台查看">
|
||
<ExternalLink />
|
||
工作台
|
||
</Link>
|
||
</Button>
|
||
) : null}
|
||
<Button variant="ghost" size="sm" onClick={() => delegateToAgent(r)}>
|
||
<Bot />
|
||
委托智能体
|
||
</Button>
|
||
<Button size="sm" onClick={onDownload}>
|
||
<Download />
|
||
下载
|
||
</Button>
|
||
</span>
|
||
</div>
|
||
</li>
|
||
);
|
||
}
|