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:
rootandClaude Opus 5.5 committed 2026-09-28 16:06:16 +08:00
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>
);
}