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,93 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ManualAcquisitionResult,
|
||||
ReleaseSearchPage,
|
||||
type ReleaseSearchSort,
|
||||
type SearchReleasePlanRequest,
|
||||
SearchResponse,
|
||||
type SearchScope,
|
||||
} from "@umt/contracts";
|
||||
import { api } from "~/lib/api.ts";
|
||||
import { getParsed, parseOrThrow } from "./library.ts";
|
||||
|
||||
// 全局搜索(W9-G):命令面板查 media/actions(快);资源结果页走 /search/releases(筛选/排序/游标,与 /search 共用 30s 缓存)。
|
||||
|
||||
export const searchKeys = {
|
||||
all: ["search"] as const,
|
||||
aggregate: (q: string, scope: SearchScope, limit: number) => ["search", "aggregate", q, scope, limit] as const,
|
||||
releases: (f: ReleaseSearchFilters) => ["search", "releases", f] as const,
|
||||
};
|
||||
|
||||
/** 聚合搜索;q 为空不发请求 */
|
||||
export function useSearch(q: string, scope: SearchScope, limit = 8, enabled = true) {
|
||||
const term = q.trim();
|
||||
return useQuery({
|
||||
queryKey: searchKeys.aggregate(term, scope, limit),
|
||||
enabled: enabled && term.length > 0,
|
||||
queryFn: ({ signal }) => getParsed(SearchResponse, "/search", { q: term, scope, limit }, signal),
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type ReleaseSearchFilters = {
|
||||
q: string;
|
||||
/** 逗号分隔 */
|
||||
sites?: string;
|
||||
resolution?: string;
|
||||
source?: string;
|
||||
/** 字节 */
|
||||
minSize?: number;
|
||||
maxSize?: number;
|
||||
free?: "true" | "false";
|
||||
hr?: "true" | "false";
|
||||
sort?: ReleaseSearchSort;
|
||||
order?: "asc" | "desc";
|
||||
};
|
||||
|
||||
/** 资源结果页:游标翻页;refresh() 带 refresh=true 跳过缓存重查第一页 */
|
||||
export function useReleaseSearch(filters: ReleaseSearchFilters, enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: searchKeys.releases(filters),
|
||||
enabled: enabled && filters.q.trim().length > 0,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
getParsed(ReleaseSearchPage, "/search/releases", { ...filters, cursor: pageParam, limit: 50 }, signal),
|
||||
getNextPageParam: (last) => last.nextCursor ?? undefined,
|
||||
placeholderData: keepPreviousData,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** 跳过缓存重查站点(仍参与并发合并),结果写回当前筛选的第一页 */
|
||||
export function useRefreshReleaseSearch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (filters: ReleaseSearchFilters) =>
|
||||
getParsed(ReleaseSearchPage, "/search/releases", { ...filters, refresh: "true", limit: 50 }),
|
||||
onSuccess: (page, filters) => {
|
||||
qc.setQueryData(searchKeys.releases(filters), { pages: [page], pageParams: [undefined] });
|
||||
void qc.invalidateQueries({ queryKey: ["search", "aggregate"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 从搜索结果下载:与 POST /acquisition-plans 同一流程 */
|
||||
export function usePlanFromSearch() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
siteId,
|
||||
siteReleaseId,
|
||||
...body
|
||||
}: SearchReleasePlanRequest & { siteId: string; siteReleaseId: string }) =>
|
||||
api
|
||||
.post<unknown>(`/search/releases/${encodeURIComponent(siteId)}/${encodeURIComponent(siteReleaseId)}/plan`, body)
|
||||
.then((d) => parseOrThrow(ManualAcquisitionResult, d, "/search/releases/plan")),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ["plans"] });
|
||||
void qc.invalidateQueries({ queryKey: ["slots"] });
|
||||
void qc.invalidateQueries({ queryKey: searchKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import type { LocalMediaHit, SearchAction, TmdbCandidate } from "@umt/contracts";
|
||||
import { ArrowRight, CornerDownLeft, Film, Globe, LoaderCircle, type LucideIcon, Plus, Search } from "lucide-react";
|
||||
import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
import { settingsPath } from "~/components/console/nav.ts";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
|
||||
import { useSubscribeTmdb } from "./search-sections.tsx";
|
||||
import {
|
||||
isSearchShortcut,
|
||||
openSearchPalette,
|
||||
searchPagePath,
|
||||
setSearchPaletteOpen,
|
||||
useSearchPalette,
|
||||
} from "./search-store.ts";
|
||||
|
||||
// 命令面板(Ctrl/⌘+K):输入即查本地作品 / TMDB 候选 / 页面与操作,最后一项跳资源结果页。
|
||||
// 资源组最慢要等 15s,面板里不查,回车进结果页。
|
||||
|
||||
function useDebounced<T>(v: T, ms: number): T {
|
||||
const [d, setD] = useState(v);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setD(v), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [v, ms]);
|
||||
return d;
|
||||
}
|
||||
|
||||
type Item = {
|
||||
key: string;
|
||||
group: string;
|
||||
icon: LucideIcon;
|
||||
title: ReactNode;
|
||||
meta?: ReactNode;
|
||||
/** 右侧动作提示 */
|
||||
hint: string;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
/** 全局快捷键 Ctrl/⌘+K:切换面板(输入框里同样生效) */
|
||||
export function useSearchHotkey() {
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.repeat || !isSearchShortcut(e)) return;
|
||||
e.preventDefault();
|
||||
openSearchPalette();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, []);
|
||||
}
|
||||
|
||||
export function CommandPalette() {
|
||||
useSearchHotkey();
|
||||
const { open, initial } = useSearchPalette();
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setSearchPaletteOpen}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
"top-4 flex translate-y-0 flex-col gap-0 overflow-hidden p-0 sm:top-[12vh] sm:max-w-xl",
|
||||
"max-sm:inset-0 max-sm:max-w-none max-sm:translate-x-0 max-sm:rounded-none",
|
||||
)}
|
||||
>
|
||||
<DialogTitle className="sr-only">搜索</DialogTitle>
|
||||
<DialogDescription className="sr-only">搜索作品、TMDB 和页面;回车在资源中搜索</DialogDescription>
|
||||
{open ? <PaletteBody initial={initial} /> : null}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function PaletteBody({ initial }: { initial: string }) {
|
||||
const navigate = useNavigate();
|
||||
const listId = useId();
|
||||
const [q, setQ] = useState(initial);
|
||||
const term = useDebounced(q.trim(), 150);
|
||||
const media = useSearch(term, "media", 6);
|
||||
const actions = useSearch(term, "actions", 6);
|
||||
const tmdbSub = useSubscribeTmdb();
|
||||
const [active, setActive] = useState(0);
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const close = () => setSearchPaletteOpen(false);
|
||||
const go = (to: string) => {
|
||||
close();
|
||||
navigate(to);
|
||||
};
|
||||
|
||||
const subscribe = (c: TmdbCandidate) => tmdbSub.subscribe(c, close);
|
||||
|
||||
const stale = q.trim() !== term;
|
||||
const local = term ? (media.data?.media?.local.items ?? []) : [];
|
||||
const tmdb = term ? media.data?.media?.tmdb : undefined;
|
||||
const acts = term ? (actions.data?.actions?.items ?? []) : [];
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只随数据变化重建(go/subscribe 每次渲染新建)
|
||||
const items = useMemo<Item[]>(() => {
|
||||
const out: Item[] = [];
|
||||
for (const h of local) out.push(localItem(h, () => go(`/console/library/${h.entity.id}`)));
|
||||
for (const c of tmdb?.items ?? [])
|
||||
out.push(tmdbItem(c, () => go(`/console/library/${c.localEntityId}`), subscribe));
|
||||
for (const a of acts) out.push(actionItem(a, () => go(a.href)));
|
||||
if (term)
|
||||
out.push({
|
||||
key: "releases",
|
||||
group: "资源",
|
||||
icon: Search,
|
||||
title: (
|
||||
<span>
|
||||
在资源中搜索 <span className="font-medium">“{term}”</span>
|
||||
</span>
|
||||
),
|
||||
meta: "查询全部已启用站点,最慢约 15 秒",
|
||||
hint: "打开",
|
||||
run: () => go(searchPagePath(term)),
|
||||
});
|
||||
return out;
|
||||
}, [local, tmdb, acts, term]);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 结果变化时高亮回到第一项
|
||||
useEffect(() => setActive(0), [items.length, term]);
|
||||
|
||||
useEffect(() => {
|
||||
listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
|
||||
}, [active]);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setActive((i) => (items.length ? (i + 1) % items.length : 0));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setActive((i) => (items.length ? (i - 1 + items.length) % items.length : 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const it = items[active];
|
||||
if (it && !stale) it.run();
|
||||
else if (q.trim()) go(searchPagePath(q));
|
||||
}
|
||||
};
|
||||
|
||||
const loading = !!term && (media.isFetching || actions.isFetching);
|
||||
const activeKey = items[active]?.key;
|
||||
let lastGroup = "";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
<div className="flex items-center gap-2 border-b border-border/70 px-4">
|
||||
<Search className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<input
|
||||
// biome-ignore lint/a11y/noAutofocus: 命令面板打开即输入
|
||||
autoFocus
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="搜索作品、TMDB、页面"
|
||||
role="combobox"
|
||||
aria-expanded={items.length > 0}
|
||||
aria-controls={listId}
|
||||
aria-activedescendant={activeKey ? `${listId}-${activeKey}` : undefined}
|
||||
aria-autocomplete="list"
|
||||
className="h-12 min-w-0 flex-1 bg-transparent text-body text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{loading ? <LoaderCircle className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" /> : null}
|
||||
<kbd className="hidden rounded-tight border border-border/70 px-1.5 text-label text-muted-foreground sm:inline">
|
||||
Esc
|
||||
</kbd>
|
||||
<button
|
||||
type="button"
|
||||
onClick={close}
|
||||
className="cursor-pointer text-caption text-muted-foreground hover:text-foreground sm:hidden"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
id={listId}
|
||||
role="listbox"
|
||||
aria-label="搜索结果"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-2 sm:max-h-[60vh]"
|
||||
>
|
||||
{!term ? (
|
||||
<p className="px-3 py-6 text-center text-caption text-muted-foreground">
|
||||
输入作品名、别名或页面名称。回车在资源中搜索。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
{items.map((it, i) => {
|
||||
const head = it.group !== lastGroup ? it.group : null;
|
||||
lastGroup = it.group;
|
||||
return (
|
||||
<div key={it.key}>
|
||||
{head ? (
|
||||
<p className="px-3 pt-2 pb-1 text-micro font-semibold tracking-[0.12em] text-muted-foreground uppercase">
|
||||
{head}
|
||||
</p>
|
||||
) : null}
|
||||
{/* biome-ignore lint/a11y/useKeyWithClickEvents: 键盘由输入框(combobox)的上下键与回车处理 */}
|
||||
<div
|
||||
id={`${listId}-${it.key}`}
|
||||
role="option"
|
||||
tabIndex={-1}
|
||||
aria-selected={i === active}
|
||||
data-index={i}
|
||||
onMouseMove={() => i !== active && setActive(i)}
|
||||
onClick={it.run}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2 transition-colors duration-micro",
|
||||
i === active ? "bg-foreground/[0.06]" : "",
|
||||
)}
|
||||
>
|
||||
<it.icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-foreground">{it.title}</div>
|
||||
{it.meta ? <div className="truncate text-meta text-muted-foreground">{it.meta}</div> : null}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1 text-meta text-muted-foreground",
|
||||
i !== active && "max-sm:hidden sm:invisible",
|
||||
)}
|
||||
>
|
||||
{it.hint}
|
||||
<CornerDownLeft className="h-3 w-3" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!stale && media.data && tmdb && !tmdb.configured ? (
|
||||
<p className="px-3 py-2 text-meta text-muted-foreground">
|
||||
TMDB 未配置,只搜本地作品。
|
||||
<Link to={settingsPath("metadata")} onClick={close} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{!stale && tmdb?.error ? (
|
||||
<p className="px-3 py-2 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
) : null}
|
||||
{media.isError || actions.isError ? (
|
||||
<p className="px-3 py-2 text-meta text-destructive">
|
||||
{errorMessage(media.error ?? actions.error, "搜索失败")}
|
||||
</p>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="hidden items-center gap-4 border-t border-border/70 px-4 py-2 text-meta text-muted-foreground sm:flex">
|
||||
<span>↑↓ 选择</span>
|
||||
<span>↵ 打开</span>
|
||||
<span className="ml-auto inline-flex items-center gap-1">
|
||||
<ArrowRight className="h-3 w-3" />
|
||||
回车在资源中搜索
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function localItem(h: LocalMediaHit, run: () => void): Item {
|
||||
const e = h.entity;
|
||||
const parts = [mediaKindLabel(e.kind), e.year ? String(e.year) : null, slotSummary(h)].filter(Boolean);
|
||||
return {
|
||||
key: `local-${e.id}`,
|
||||
group: "本地作品",
|
||||
icon: Film,
|
||||
title: (
|
||||
<span>
|
||||
{e.title}
|
||||
{h.matchedAlias && h.matchedAlias !== e.title ? (
|
||||
<span className="ml-1.5 text-muted-foreground">· {h.matchedAlias}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
meta: parts.join(" · "),
|
||||
hint: "打开",
|
||||
run,
|
||||
};
|
||||
}
|
||||
|
||||
function tmdbItem(c: TmdbCandidate, view: () => void, subscribe: (c: TmdbCandidate) => void): Item {
|
||||
const local = !!c.localEntityId;
|
||||
return {
|
||||
key: `tmdb-${c.entityType}-${c.externalId}`,
|
||||
group: "TMDB",
|
||||
icon: local ? Globe : Plus,
|
||||
title: (
|
||||
<span>
|
||||
{c.title}
|
||||
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
|
||||
</span>
|
||||
),
|
||||
meta: [c.entityType === "movie" ? "电影" : "剧集", local ? "本地已有" : c.originalTitle, c.overview]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
hint: local ? "查看" : "订阅",
|
||||
run: local ? view : () => subscribe(c),
|
||||
};
|
||||
}
|
||||
|
||||
function actionItem(a: SearchAction, run: () => void): Item {
|
||||
return {
|
||||
key: `action-${a.id}`,
|
||||
group: "页面与操作",
|
||||
icon: actionIcon(a.kind),
|
||||
title: a.title,
|
||||
meta: a.group,
|
||||
hint: "前往",
|
||||
run,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import type { ManualAcquisitionResult, SearchRelease } from "@umt/contracts";
|
||||
import { Bot, Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { usePlanFromSearch } from "~/api/search.ts";
|
||||
import { useSlots } from "~/api/slots.ts";
|
||||
import { openAgentDrawer } from "~/components/agent/agent-store.ts";
|
||||
import { FilterSelect } from "~/components/console/filter-bar.tsx";
|
||||
import { SLOT_STATUS } from "~/components/library/labels.ts";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
// 与站点工作台同一套下载说明;请求走 POST /search/releases/{siteId}/{siteReleaseId}/plan(同 /acquisition-plans 流程)
|
||||
|
||||
const STEPS = [
|
||||
"生成下载计划,预留版本目标和预算;每个版本目标同时只有一个在途计划",
|
||||
"已有相同资源或相同 hash 的任务时直接接管,不重复提交",
|
||||
"缓存卷超过硬阈值或读不到磁盘状态时无法提交",
|
||||
"提交前复核免费、做种等易变字段",
|
||||
];
|
||||
|
||||
/** 委托 Agent:和工作台一样带上资源、作品、站点 */
|
||||
export function delegateToAgent(r: SearchRelease) {
|
||||
openAgentDrawer({
|
||||
label: `资源:${r.title}`,
|
||||
releaseId: r.sourceReleaseId ?? undefined,
|
||||
entityId: r.entity?.id,
|
||||
siteId: r.siteId,
|
||||
goal: r.sourceReleaseId
|
||||
? "判断这个资源是否值得下载;合适就按正常流程提交"
|
||||
: `判断站点 ${r.siteName} 的资源「${r.title}」是否值得下载;合适就按正常流程提交`,
|
||||
});
|
||||
}
|
||||
|
||||
export function SearchDownloadDialog({
|
||||
release,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
release: SearchRelease;
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
}) {
|
||||
const plan = usePlanFromSearch();
|
||||
const entityId = release.entity?.id;
|
||||
const slots = useSlots({ mediaEntityId: entityId }, open && !!entityId);
|
||||
const slotList = slots.data?.items ?? [];
|
||||
const [slotId, setSlotId] = useState("");
|
||||
const [rejected, setRejected] = useState<ManualAcquisitionResult["rejected"] | null>(null);
|
||||
const [done, setDone] = useState<string | null>(null);
|
||||
const slotName = (id: string | null) => slotList.find((s) => s.id === id)?.name ?? null;
|
||||
|
||||
const go = () =>
|
||||
plan.mutate(
|
||||
{
|
||||
siteId: release.siteId,
|
||||
siteReleaseId: release.siteReleaseId,
|
||||
...(slotId ? { slotId } : entityId ? { entityId } : {}),
|
||||
},
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
if (r.submitted && r.plan) {
|
||||
setRejected(null);
|
||||
setDone(r.plan.id);
|
||||
toast.success(`已提交${slotName(r.slotId) ? `(${slotName(r.slotId)})` : ""},进度见“下载队列”`);
|
||||
} else {
|
||||
setRejected(r.rejected);
|
||||
toast.warning("没有生成下载计划");
|
||||
}
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
setRejected(null);
|
||||
setDone(null);
|
||||
}
|
||||
onOpenChange(o);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>下载这个资源</DialogTitle>
|
||||
<DialogDescription className="break-all">
|
||||
{release.siteName} · {release.title}
|
||||
{release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ol className="list-decimal space-y-1 pl-5 text-caption text-muted-foreground">
|
||||
{STEPS.map((s) => (
|
||||
<li key={s}>{s}</li>
|
||||
))}
|
||||
</ol>
|
||||
{!entityId ? (
|
||||
<p className="text-meta text-muted-foreground">资源未关联本地作品。先在“身份与元数据”指定。</p>
|
||||
) : slots.isPending ? (
|
||||
<p className="text-meta text-muted-foreground">正在读取版本目标…</p>
|
||||
) : slotList.length ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<FilterSelect
|
||||
label="版本目标"
|
||||
value={slotId}
|
||||
onChange={setSlotId}
|
||||
allLabel="自动匹配"
|
||||
options={slotList.map((s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}(${SLOT_STATUS[s.status]?.label ?? s.status})`,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-meta text-muted-foreground">这个作品没有版本目标。先在订阅里新建。</p>
|
||||
)}
|
||||
{release.entityMatch === "alias" ? (
|
||||
<p className="text-meta text-warning">作品关联来自标题别名,尚未确认。</p>
|
||||
) : null}
|
||||
{rejected ? (
|
||||
<div className="rounded-xl border border-warning/40 bg-warning/10 px-3 py-2.5 text-caption text-warning">
|
||||
<p className="mb-1">没有生成下载计划:</p>
|
||||
<ul className="list-disc space-y-0.5 pl-5">
|
||||
{rejected.map((r, i) => (
|
||||
<li key={`${r.slotId}-${r.code}-${i}`} className="break-words">
|
||||
{slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""}
|
||||
{r.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
{done ? (
|
||||
<p className="text-caption text-success">
|
||||
计划已生成。
|
||||
<Link to="/console/downloads/plans" className="underline underline-offset-4">
|
||||
查看
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onOpenChange(false);
|
||||
delegateToAgent(release);
|
||||
}}
|
||||
>
|
||||
<Bot />
|
||||
委托 Agent
|
||||
</Button>
|
||||
<Button onClick={go} disabled={plan.isPending || !!done || !entityId}>
|
||||
<Download />
|
||||
{plan.isPending ? "提交中…" : "提交下载"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { LocalMediaHit, MediaKind, SearchInventoryState, SearchSiteStatus } from "@umt/contracts";
|
||||
import { Compass, type LucideIcon, Zap } from "lucide-react";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
import { MEDIA_KIND } from "~/components/library/labels.ts";
|
||||
|
||||
// 搜索结果的展示口径
|
||||
|
||||
export const mediaKindLabel = (k: MediaKind) => MEDIA_KIND[k] ?? k;
|
||||
|
||||
/** 本地作品的一行摘要:订阅 / 版本 / 下载中 / 缺口 */
|
||||
export function slotSummary(h: LocalMediaHit): string {
|
||||
const c = h.slotCounts;
|
||||
const parts: string[] = [];
|
||||
if (h.subscribed) parts.push("已订阅");
|
||||
if (h.versionCount) parts.push(`${h.versionCount} 个版本`);
|
||||
if (h.downloadingCount) parts.push(`${h.downloadingCount} 个下载中`);
|
||||
const gaps = c.empty + c.attention + c.upgrade_pending;
|
||||
if (gaps) parts.push(`${gaps} 个版本目标未满足`);
|
||||
return parts.join(" · ") || "未订阅";
|
||||
}
|
||||
|
||||
export const actionIcon = (kind: "page" | "action"): LucideIcon => (kind === "page" ? Compass : Zap);
|
||||
|
||||
export const INVENTORY_STATE: Record<SearchInventoryState, { label: string; tone: StatusTone }> = {
|
||||
downloading: { label: "下载中", tone: "warning" },
|
||||
gap: { label: "有缺口", tone: "warning" },
|
||||
owned: { label: "已有版本", tone: "success" },
|
||||
subscribed: { label: "已订阅", tone: "neutral" },
|
||||
none: { label: "本地无版本", tone: "neutral" },
|
||||
};
|
||||
|
||||
export const SITE_STATUS: Record<SearchSiteStatus, string> = {
|
||||
ok: "正常",
|
||||
failed: "失败",
|
||||
timeout: "超时",
|
||||
skipped: "跳过",
|
||||
};
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: "default", label: "默认" },
|
||||
{ value: "seeders", label: "做种数" },
|
||||
{ value: "size", label: "大小" },
|
||||
{ value: "uploadedAt", label: "发布时间" },
|
||||
{ value: "site", label: "站点" },
|
||||
] as const;
|
||||
|
||||
const PROMO: Record<string, string> = {
|
||||
free: "免费",
|
||||
"2xfree": "2x 免费",
|
||||
"50%": "50%",
|
||||
"2x50%": "2x 50%",
|
||||
"30%": "30%",
|
||||
"2x": "2x 上传",
|
||||
};
|
||||
/** 促销文案;none/unknown 返回 null */
|
||||
export function promoLabel(type: string): string | null {
|
||||
if (!type || type === "none" || type === "unknown") return null;
|
||||
return PROMO[type] ?? type;
|
||||
}
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
export const gbToBytes = (s: string): number | undefined => {
|
||||
const n = Number(s);
|
||||
return s.trim() && Number.isFinite(n) && n >= 0 ? Math.round(n * GB) : undefined;
|
||||
};
|
||||
@@ -0,0 +1,175 @@
|
||||
import type { TmdbCandidate } from "@umt/contracts";
|
||||
import { ArrowRight, Film, Plus } from "lucide-react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useSearch } from "~/api/search.ts";
|
||||
import { useCreateSubscription } from "~/api/subscriptions.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { settingsPath } from "~/components/console/nav.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { errorMessage } from "~/lib/api.ts";
|
||||
import { actionIcon, mediaKindLabel, slotSummary } from "./search-labels.ts";
|
||||
|
||||
// 结果页的"作品"与"操作"两段(资源段见 release-results.tsx)
|
||||
|
||||
function SectionTitle({ id, title, extra }: { id: string; title: string; extra?: string }) {
|
||||
return (
|
||||
<h2 id={id} className="mb-3 text-body font-semibold text-foreground">
|
||||
{title}
|
||||
{extra ? <span className="ml-2 text-caption font-normal text-muted-foreground tabular-nums">{extra}</span> : null}
|
||||
</h2>
|
||||
);
|
||||
}
|
||||
|
||||
/** 从 TMDB 候选建订阅:按标题追踪(建档后自动关联作品) */
|
||||
export function useSubscribeTmdb() {
|
||||
const create = useCreateSubscription();
|
||||
const navigate = useNavigate();
|
||||
return {
|
||||
pending: create.isPending,
|
||||
subscribe: (c: TmdbCandidate, onDone?: () => void) => {
|
||||
const kind = c.entityType === "movie" ? "movie" : "series";
|
||||
create.mutate(
|
||||
{
|
||||
titleQuery: c.year ? `${c.title} ${c.year}` : c.title,
|
||||
mediaKind: kind,
|
||||
mode: kind === "movie" ? "once" : "continuous",
|
||||
seasonScope: kind === "movie" ? "current_season" : "following_seasons",
|
||||
priority: "new_work",
|
||||
origin: "user",
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(`已订阅「${c.title}」`, {
|
||||
action: { label: "查看", onClick: () => navigate("/console/subscriptions") },
|
||||
});
|
||||
onDone?.();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e, "订阅失败")),
|
||||
},
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function MediaSection({ q }: { q: string }) {
|
||||
const res = useSearch(q, "media", 20);
|
||||
const sub = useSubscribeTmdb();
|
||||
const local = res.data?.media?.local.items ?? [];
|
||||
const tmdb = res.data?.media?.tmdb;
|
||||
return (
|
||||
<section aria-labelledby="search-media" className="min-w-0">
|
||||
<SectionTitle
|
||||
id="search-media"
|
||||
title="作品"
|
||||
extra={res.data ? `${local.length + (tmdb?.items.length ?? 0)} 条` : undefined}
|
||||
/>
|
||||
<div className="pg-slab min-w-0 overflow-hidden rounded-xl">
|
||||
{res.isPending ? (
|
||||
<LoadingState rows={2} className="p-4" />
|
||||
) : res.isError ? (
|
||||
<ErrorState plain error={res.error} onRetry={() => res.refetch()} />
|
||||
) : local.length === 0 && !tmdb?.items.length ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={Film}
|
||||
title="没有匹配的作品"
|
||||
description="换个译名、原名或别名再搜。"
|
||||
className="py-8"
|
||||
/>
|
||||
) : (
|
||||
<ul className="divide-y divide-border/60">
|
||||
{local.map((h) => (
|
||||
<li key={h.entity.id}>
|
||||
<Link
|
||||
to={`/console/library/${h.entity.id}`}
|
||||
className="flex min-w-0 items-center gap-3 px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<Film className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{h.entity.title}
|
||||
{h.entity.year ? <span className="text-muted-foreground"> ({h.entity.year})</span> : null}
|
||||
</p>
|
||||
<p className="truncate text-meta text-muted-foreground">
|
||||
{mediaKindLabel(h.entity.kind)} · {slotSummary(h)}
|
||||
{h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge>本地</StatusBadge>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
{(tmdb?.items ?? []).map((c) => (
|
||||
<li key={`${c.entityType}-${c.externalId}`} className="flex min-w-0 items-center gap-3 px-4 py-3">
|
||||
<Plus className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{c.title}
|
||||
{c.year ? <span className="text-muted-foreground"> ({c.year})</span> : null}
|
||||
</p>
|
||||
<p className="line-clamp-1 text-meta text-muted-foreground">
|
||||
TMDB · {c.entityType === "movie" ? "电影" : "剧集"}
|
||||
{c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""}
|
||||
{c.overview ? ` · ${c.overview}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
{c.localEntityId ? (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Link to={`/console/library/${c.localEntityId}`}>查看</Link>
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="outline" size="sm" disabled={sub.pending} onClick={() => sub.subscribe(c)}>
|
||||
订阅
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tmdb && !tmdb.configured ? (
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-muted-foreground">
|
||||
TMDB 未配置,只搜了本地作品。
|
||||
<Link to={settingsPath("metadata")} className="ml-1 underline underline-offset-4">
|
||||
去配置
|
||||
</Link>
|
||||
</p>
|
||||
) : null}
|
||||
{tmdb?.error ? (
|
||||
<p className="border-t border-border/60 px-4 py-2.5 text-meta text-warning">TMDB 查询失败:{tmdb.error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ActionsSection({ q }: { q: string }) {
|
||||
const res = useSearch(q, "actions", 12);
|
||||
const items = res.data?.actions?.items ?? [];
|
||||
if (res.isPending || res.isError || items.length === 0) return null;
|
||||
return (
|
||||
<section aria-labelledby="search-actions" className="min-w-0">
|
||||
<SectionTitle id="search-actions" title="操作" />
|
||||
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{items.map((a) => {
|
||||
const Icon = actionIcon(a.kind);
|
||||
return (
|
||||
<Link
|
||||
key={a.id}
|
||||
to={a.href}
|
||||
className="pg-slab flex min-w-0 items-center gap-3 rounded-xl px-4 py-3 transition-colors duration-micro hover:bg-foreground/[0.04]"
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-foreground">{a.title}</span>
|
||||
<span className="block truncate text-meta text-muted-foreground">{a.group}</span>
|
||||
</span>
|
||||
<ArrowRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
// 命令面板的全局开关:顶栏搜索框、Ctrl/⌘+K 共用。Agent 抽屉用 Ctrl/⌘+J,两者互不占用。
|
||||
|
||||
type State = { open: boolean; initial: string };
|
||||
|
||||
let state: State = { open: false, initial: "" };
|
||||
const listeners = new Set<() => void>();
|
||||
const emit = () => {
|
||||
for (const l of listeners) l();
|
||||
};
|
||||
|
||||
export function openSearchPalette(initial = "") {
|
||||
state = { open: true, initial };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function setSearchPaletteOpen(open: boolean) {
|
||||
state = { ...state, open };
|
||||
emit();
|
||||
}
|
||||
|
||||
export function useSearchPalette(): State {
|
||||
return useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => state,
|
||||
() => state,
|
||||
);
|
||||
}
|
||||
|
||||
/** 快捷键文案:Mac 显示 ⌘K,其余 Ctrl K */
|
||||
export function searchShortcutLabel(): string {
|
||||
const mac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
|
||||
return mac ? "⌘K" : "Ctrl K";
|
||||
}
|
||||
|
||||
/** Ctrl/⌘+K(不带 Shift/Alt);输入框里同样生效 */
|
||||
export function isSearchShortcut(
|
||||
e: Pick<KeyboardEvent, "key" | "ctrlKey" | "metaKey" | "shiftKey" | "altKey">,
|
||||
): boolean {
|
||||
return (e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === "k";
|
||||
}
|
||||
|
||||
/** 结果页地址 */
|
||||
export const searchPagePath = (q: string) => `/console/search?q=${encodeURIComponent(q.trim())}`;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { openSearchPalette, searchShortcutLabel } from "./search-store.ts";
|
||||
|
||||
/** 顶栏搜索入口:桌面是带快捷键提示的输入框样按钮,手机是图标按钮;都打开命令面板 */
|
||||
export function SearchTrigger({ className }: { className?: string }) {
|
||||
// 快捷键文案依赖 navigator,挂载后再算,避免水合不一致
|
||||
const [shortcut, setShortcut] = useState("Ctrl K");
|
||||
useEffect(() => setShortcut(searchShortcutLabel()), []);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openSearchPalette()}
|
||||
aria-label="搜索"
|
||||
aria-keyshortcuts="Control+K Meta+K"
|
||||
className={cn(
|
||||
"hidden h-8 w-56 cursor-pointer items-center gap-2 rounded-full border border-border/80 bg-foreground/[0.03] px-3 text-caption text-muted-foreground transition-colors duration-micro outline-none hover:bg-foreground/[0.06] hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 md:flex lg:w-72",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Search className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1 truncate text-left">搜索作品、资源、页面</span>
|
||||
<kbd className="rounded-tight border border-border/70 px-1 font-sans text-label leading-4 font-medium">
|
||||
{shortcut}
|
||||
</kbd>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 md:hidden"
|
||||
aria-label="搜索"
|
||||
onClick={() => openSearchPalette()}
|
||||
>
|
||||
<Search className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export default [
|
||||
route("audit", "routes/console/audit.tsx"),
|
||||
route("settings", "routes/console/settings.tsx"),
|
||||
route("schedules", "routes/console/schedules.tsx"),
|
||||
route("search", "routes/console/search.tsx"),
|
||||
]),
|
||||
]),
|
||||
route("*", "routes/not-found.tsx"),
|
||||
|
||||
@@ -10,6 +10,8 @@ import { AgentLauncher, AgentNavEntry } from "~/components/console/agent-launche
|
||||
import { ErrorState } from "~/components/console/empty-state.tsx";
|
||||
import { findNavItem, NAV_GROUPS, type NavGroup, type NavItem } from "~/components/console/nav.ts";
|
||||
import PrismLeak from "~/components/prism-leak.tsx";
|
||||
import { CommandPalette } from "~/components/search/command-palette.tsx";
|
||||
import { SearchTrigger } from "~/components/search/search-trigger.tsx";
|
||||
import { ThemeToggle } from "~/components/theme-toggle.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "~/components/ui/sheet.tsx";
|
||||
@@ -260,6 +262,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
<span className="truncate text-sm font-semibold text-foreground">{activeLabel}</span>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<SearchTrigger />
|
||||
{/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */}
|
||||
<AgentDrawerTrigger className="xl:hidden" />
|
||||
<ThemeToggle />
|
||||
@@ -275,6 +278,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) {
|
||||
</main>
|
||||
<AgentDrawer />
|
||||
<AgentLauncher />
|
||||
<CommandPalette />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Search } from "lucide-react";
|
||||
import { type FormEvent, useEffect, useState } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { EmptyState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { useQueryState } from "~/components/console/use-query-state.ts";
|
||||
import { RELEASE_FILTER_KEYS, type ReleaseFilterState, ReleaseResults } from "~/components/search/release-results.tsx";
|
||||
import { ActionsSection, MediaSection } from "~/components/search/search-sections.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
|
||||
// 全局搜索结果页(W9-G):/console/search?q= 三段 —— 作品 / 资源 / 操作;q 与资源筛选都写在 URL 里。
|
||||
|
||||
export const meta = () => [{ title: "搜索 · UHD 媒体自动化控制台" }];
|
||||
|
||||
export default function SearchPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const q = (params.get("q") ?? "").trim();
|
||||
const [input, setInput] = useState(q);
|
||||
useEffect(() => setInput(q), [q]);
|
||||
const f = useQueryState(RELEASE_FILTER_KEYS);
|
||||
|
||||
const submit = (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const next = input.trim();
|
||||
if (!next || next === q) return;
|
||||
// 换关键词时筛选随之清空(站点/分辨率分面随结果变化)
|
||||
setParams(new URLSearchParams({ q: next }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="搜索" description="本地作品、TMDB、各站点资源和控制台页面。" />
|
||||
<form onSubmit={submit} className="mb-6 flex gap-2">
|
||||
<div className="relative min-w-0 flex-1 sm:max-w-xl">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="作品名、别名或资源标题"
|
||||
aria-label="搜索关键词"
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={!input.trim()}>
|
||||
搜索
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{!q ? (
|
||||
<EmptyState icon={Search} title="输入关键词开始搜索" description="也可以在任意页面按 Ctrl/⌘ K 打开搜索。" />
|
||||
) : (
|
||||
<div key={q} className="space-y-8">
|
||||
<MediaSection q={q} />
|
||||
<ReleaseResults q={q} filters={f.values as ReleaseFilterState} setFilter={f.set} resetFilters={f.reset} />
|
||||
<ActionsSection q={q} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,10 @@ const STATIC_ROUTES = [
|
||||
"/console/files/trash",
|
||||
"/console/subscriptions",
|
||||
"/console/rules",
|
||||
"/console/preferences",
|
||||
"/console/rules?tab=atoms",
|
||||
"/console/rules?tab=defaults",
|
||||
"/console/rules?tab=policies",
|
||||
"/console/rules?tab=suppressions",
|
||||
"/console/sites",
|
||||
"/console/sources",
|
||||
"/console/downloads",
|
||||
@@ -42,6 +45,7 @@ const STATIC_ROUTES = [
|
||||
"/console/engine",
|
||||
"/console/audit",
|
||||
"/console/schedules",
|
||||
"/console/search?q=沙丘",
|
||||
"/console/settings",
|
||||
"/console/settings?tab=pi",
|
||||
// 设置各分区(hash 定位,IA-2)
|
||||
@@ -77,6 +81,9 @@ const REDIRECT_ROUTES = [
|
||||
{ from: "/console/tasks?tab=plans", to: "/console/downloads/plans" },
|
||||
{ from: "/console/tasks?tab=tasks", to: "/console/tasks" },
|
||||
{ from: "/console/subscriptions?tab=policies", to: "/console/rules" },
|
||||
// 偏好标签并入规则页(origin 筛选透传)
|
||||
{ from: "/console/preferences", to: "/console/rules" },
|
||||
{ from: "/console/preferences?origin=agent_inferred", to: "/console/rules?origin=agent_inferred" },
|
||||
{ from: "/console/sources?tab=snapshots", to: "/console/sources" },
|
||||
{ from: "/console/sources?tab=snapshots&siteId=:id", to: "/console/sites/:id?tab=snapshots", list: "/api/v1/sites" },
|
||||
// 旧的下载详情地址:先查业务任务(404 属预期),再跳到下载详情
|
||||
|
||||
Reference in new issue
Block a user