feat(web): global search — top bar entry, Ctrl/Cmd+K palette and /console/search results (FE-3)
- palette queries media + actions: local works open, TMDB candidates view/subscribe, page jumps
- results page: works / releases / actions; release filters, facets, sort, cursor, partial-site warning
- release rows: download via /search/releases/{siteId}/{siteReleaseId}/plan, delegate to Agent
- smoke: rules tabs, preferences redirect, search page
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2c6a89f309
commit
393a4cadcc
12 files changed
+1399
-1
No files matched your search
@@ -0,0 +1,411 @@
|
||||
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";
|
||||
|
||||
// 资源段:跨站结果的筛选(站点/分辨率/来源/大小/免费/H&R)、排序、游标;行操作同站点工作台。
|
||||
|
||||
/** 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 function toApiFilters(q: string, f: ReleaseFilterState): 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 {
|
||||
q,
|
||||
sites: 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({
|
||||
q,
|
||||
filters,
|
||||
setFilter,
|
||||
resetFilters,
|
||||
}: {
|
||||
q: string;
|
||||
filters: ReleaseFilterState;
|
||||
setFilter: (k: keyof ReleaseFilterState, v: string) => void;
|
||||
resetFilters: () => void;
|
||||
}) {
|
||||
const api = useMemo(() => toApiFilters(q, filters), [q, filters]);
|
||||
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 = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]);
|
||||
|
||||
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 text-muted-foreground tabular-nums">
|
||||
{first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
{first ? <CoverageNotice page={first} /> : null}
|
||||
|
||||
{first ? (
|
||||
<Filters page={first} filters={filters} setFilter={setFilter} onReset={filtered ? resetFilters : 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>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="mb-3 space-y-2.5">
|
||||
{facets
|
||||
.filter((f) => f.items.length > 0)
|
||||
.map((f) => (
|
||||
<div key={f.key} className="flex min-w-0 flex-wrap items-start gap-2">
|
||||
<span className="w-12 shrink-0 pt-1 text-meta text-muted-foreground">{f.label}</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<ChipToggleGroup
|
||||
label={f.label}
|
||||
options={facetOpts(f.items)}
|
||||
value={csvList(filters[f.key])}
|
||||
onChange={(v) => setFilter(f.key, v.join(","))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
<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 />
|
||||
委托 Agent
|
||||
</Button>
|
||||
<Button size="sm" onClick={onDownload}>
|
||||
<Download />
|
||||
下载
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user