diff --git a/apps/web/app/api/search.ts b/apps/web/app/api/search.ts new file mode 100644 index 00000000..392947a2 --- /dev/null +++ b/apps/web/app/api/search.ts @@ -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(`/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 }); + }, + }); +} diff --git a/apps/web/app/components/search/command-palette.tsx b/apps/web/app/components/search/command-palette.tsx new file mode 100644 index 00000000..d5325780 --- /dev/null +++ b/apps/web/app/components/search/command-palette.tsx @@ -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(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 ( + + + 搜索 + 搜索作品、TMDB 和页面;回车在资源中搜索 + {open ? : null} + + + ); +} + +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(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(() => { + 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: ( + + 在资源中搜索 “{term}” + + ), + 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 ( +
+
+ + 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 ? : null} + + Esc + + +
+ +
+ {!term ? ( +

+ 输入作品名、别名或页面名称。回车在资源中搜索。 +

+ ) : ( + <> + {items.map((it, i) => { + const head = it.group !== lastGroup ? it.group : null; + lastGroup = it.group; + return ( +
+ {head ? ( +

+ {head} +

+ ) : null} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: 键盘由输入框(combobox)的上下键与回车处理 */} +
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.title}
+ {it.meta ?
{it.meta}
: null} +
+ + {it.hint} + + +
+
+ ); + })} + {!stale && media.data && tmdb && !tmdb.configured ? ( +

+ TMDB 未配置,只搜本地作品。 + + 去配置 + +

+ ) : null} + {!stale && tmdb?.error ? ( +

TMDB 查询失败:{tmdb.error}

+ ) : null} + {media.isError || actions.isError ? ( +

+ {errorMessage(media.error ?? actions.error, "搜索失败")} +

+ ) : null} + + )} +
+ +
+ ↑↓ 选择 + ↵ 打开 + + + 回车在资源中搜索 + +
+
+ ); +} + +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: ( + + {e.title} + {h.matchedAlias && h.matchedAlias !== e.title ? ( + · {h.matchedAlias} + ) : null} + + ), + 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: ( + + {c.title} + {c.year ? ({c.year}) : null} + + ), + 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, + }; +} diff --git a/apps/web/app/components/search/release-results.tsx b/apps/web/app/components/search/release-results.tsx new file mode 100644 index 00000000..34adc701 --- /dev/null +++ b/apps/web/app/components/search/release-results.tsx @@ -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(null); + const filtered = RELEASE_FILTER_KEYS.some((k) => k !== "sort" && k !== "order" && !!filters[k]); + + return ( +
+
+
+

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

+ {first ? ( +

+ {first.cached ? "缓存结果" : "站点实时结果"} · 抓取于 {relTime(first.fetchedAt)} · {first.tookMs} ms +

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

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

+ +
+ ) : res.isError && !rows.length ? ( + res.refetch()} /> + ) : rows.length === 0 ? ( + + ) : ( +
    + {rows.map((r) => ( + setDl(r)} /> + ))} +
+ )} + {rows.length > 0 && res.hasNextPage ? ( +
+ +
+ ) : null} +
+ + {dl ? !o && setDl(null)} /> : null} +
+ ); +} + +/** 部分站点失败 / 跳过:列出站点,其余结果照常 */ +function CoverageNotice({ page }: { page: ReleaseSearchPage }) { + const [open, setOpen] = useState(false); + const skipped = page.sites.filter((s) => s.status === "skipped"); + const ok = page.sites.filter((s) => s.status === "ok").length; + return ( +
+ {page.partial ? ( +
+ +
+

+ {page.failedSites.length} 个站点没有返回结果,下面只有 {ok} 个站点的资源。 +

+
    + {page.failedSites.map((s) => ( +
  • + {s.siteName}:{SITE_STATUS[s.status]} + {s.error ? `(${s.error})` : ""} +
  • + ))} +
+
+
+ ) : null} + {skipped.length ? ( +
+ + {open ? ( +
    + {skipped.map((s) => ( +
  • + {s.siteName}:{s.error ?? "站点不支持搜索"} +
  • + ))} +
+ ) : null} +
+ ) : null} +
+ ); +} + +const facetOpts = (items: FacetCount[]) => + items.map((f) => ({ value: f.value, label: `${f.value === "unknown" ? "未知" : f.label} ${f.count}` })); + +function Filters({ + page, + filters, + setFilter, + onReset, +}: { + page: ReleaseSearchPage; + filters: ReleaseFilterState; + setFilter: (k: keyof ReleaseFilterState, v: string) => void; + onReset?: () => void; +}) { + const [minGb, setMinGb] = useState(filters.minGb); + const [maxGb, setMaxGb] = useState(filters.maxGb); + useEffect(() => setMinGb(filters.minGb), [filters.minGb]); + useEffect(() => setMaxGb(filters.maxGb), [filters.maxGb]); + const facets: { key: "sites" | "resolution" | "source"; label: string; items: FacetCount[] }[] = [ + { key: "sites", label: "站点", items: page.facets.sites }, + { key: "resolution", label: "分辨率", items: page.facets.resolutions }, + { key: "source", label: "来源", items: page.facets.sources }, + ]; + const desc = filters.order !== "asc"; + + return ( +
+ {facets + .filter((f) => f.items.length > 0) + .map((f) => ( +
+ {f.label} +
+ setFilter(f.key, v.join(","))} + /> +
+
+ ))} +
+
+ 大小 + setMinGb(e.target.value)} + onBlur={() => setFilter("minGb", minGb.trim())} + onKeyDown={(e) => e.key === "Enter" && setFilter("minGb", minGb.trim())} + placeholder="最小" + aria-label="最小大小(GB)" + className="h-8 w-20" + /> + – + setMaxGb(e.target.value)} + onBlur={() => setFilter("maxGb", maxGb.trim())} + onKeyDown={(e) => e.key === "Enter" && setFilter("maxGb", maxGb.trim())} + placeholder="最大" + aria-label="最大大小(GB)" + className="h-8 w-20" + /> + GB +
+ setFilter("free", v)} + options={[ + { value: "true", label: "只看免费" }, + { value: "false", label: "排除免费" }, + ]} + /> + setFilter("hr", v)} + options={[ + { value: "true", label: "只看 H&R" }, + { value: "false", label: "排除 H&R" }, + ]} + /> +
+ + +
+ {onReset ? ( + + ) : null} +
+
+ ); +} + +function ReleaseRow({ r, onDownload }: { r: SearchRelease; onDownload: () => void }) { + const promo = promoLabel(r.promotion.type); + const inv = r.inventory ? INVENTORY_STATE[r.inventory.state] : null; + const p = r.parsed; + const tech = [p.resolution, p.source, p.videoCodec, ...p.hdr, p.releaseGroup].filter(Boolean).join(" · "); + return ( +
  • +
    + {r.title} + + {promo ? {promo} : null} + {r.hr === true ? H&R : null} + {r.hr === "unknown" ? H&R 未知 : null} + +
    + {r.subtitle ?

    {r.subtitle}

    : null} +
    + {r.siteName} + {r.category.name ? {r.category.name} : null} + {r.sizeBytes === null ? "大小未知" : formatBytes(r.sizeBytes, 1)} + + + {r.seeders ?? "?"} + + {r.leechers ?? "?"} + + 发布 {relTime(r.uploadedAt)} + {tech ? {tech} : null} +
    +
    + + {r.entity ? ( + <> + + {r.entity.title} + {r.entity.year ? ` (${r.entity.year})` : ""} + + {r.entityMatch === "alias" ? 未确认 : null} + {inv ? {inv.label} : null} + + ) : ( + 未关联本地作品 + )} + + + {r.sourceReleaseId ? ( + + ) : null} + + + +
    +
  • + ); +} diff --git a/apps/web/app/components/search/search-download-dialog.tsx b/apps/web/app/components/search/search-download-dialog.tsx new file mode 100644 index 00000000..3b3babf3 --- /dev/null +++ b/apps/web/app/components/search/search-download-dialog.tsx @@ -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(null); + const [done, setDone] = useState(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 ( + { + if (!o) { + setRejected(null); + setDone(null); + } + onOpenChange(o); + }} + > + + + 下载这个资源 + + {release.siteName} · {release.title} + {release.sizeBytes !== null ? ` · ${formatBytes(release.sizeBytes, 1)}` : ""} + + +
      + {STEPS.map((s) => ( +
    1. {s}
    2. + ))} +
    + {!entityId ? ( +

    资源未关联本地作品。先在“身份与元数据”指定。

    + ) : slots.isPending ? ( +

    正在读取版本目标…

    + ) : slotList.length ? ( +
    + ({ + value: s.id, + label: `${s.name}(${SLOT_STATUS[s.status]?.label ?? s.status})`, + }))} + /> +
    + ) : ( +

    这个作品没有版本目标。先在订阅里新建。

    + )} + {release.entityMatch === "alias" ? ( +

    作品关联来自标题别名,尚未确认。

    + ) : null} + {rejected ? ( +
    +

    没有生成下载计划:

    +
      + {rejected.map((r, i) => ( +
    • + {slotName(r.slotId) ? `${slotName(r.slotId)}:` : ""} + {r.message} +
    • + ))} +
    +
    + ) : null} + {done ? ( +

    + 计划已生成。 + + 查看 + +

    + ) : null} + + + + +
    +
    + ); +} diff --git a/apps/web/app/components/search/search-labels.ts b/apps/web/app/components/search/search-labels.ts new file mode 100644 index 00000000..ad4f497a --- /dev/null +++ b/apps/web/app/components/search/search-labels.ts @@ -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 = { + 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 = { + 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 = { + 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; +}; diff --git a/apps/web/app/components/search/search-sections.tsx b/apps/web/app/components/search/search-sections.tsx new file mode 100644 index 00000000..a561e805 --- /dev/null +++ b/apps/web/app/components/search/search-sections.tsx @@ -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 ( +

    + {title} + {extra ? {extra} : null} +

    + ); +} + +/** 从 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 ( +
    + +
    + {res.isPending ? ( + + ) : res.isError ? ( + res.refetch()} /> + ) : local.length === 0 && !tmdb?.items.length ? ( + + ) : ( +
      + {local.map((h) => ( +
    • + + +
      +

      + {h.entity.title} + {h.entity.year ? ({h.entity.year}) : null} +

      +

      + {mediaKindLabel(h.entity.kind)} · {slotSummary(h)} + {h.matchedAlias && h.matchedAlias !== h.entity.title ? ` · 命中别名 ${h.matchedAlias}` : ""} +

      +
      + 本地 + +
    • + ))} + {(tmdb?.items ?? []).map((c) => ( +
    • + +
      +

      + {c.title} + {c.year ? ({c.year}) : null} +

      +

      + TMDB · {c.entityType === "movie" ? "电影" : "剧集"} + {c.originalTitle && c.originalTitle !== c.title ? ` · ${c.originalTitle}` : ""} + {c.overview ? ` · ${c.overview}` : ""} +

      +
      + {c.localEntityId ? ( + + ) : ( + + )} +
    • + ))} +
    + )} + {tmdb && !tmdb.configured ? ( +

    + TMDB 未配置,只搜了本地作品。 + + 去配置 + +

    + ) : null} + {tmdb?.error ? ( +

    TMDB 查询失败:{tmdb.error}

    + ) : null} +
    +
    + ); +} + +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 ( +
    + +
    + {items.map((a) => { + const Icon = actionIcon(a.kind); + return ( + + + + {a.title} + {a.group} + + + + ); + })} +
    +
    + ); +} diff --git a/apps/web/app/components/search/search-store.ts b/apps/web/app/components/search/search-store.ts new file mode 100644 index 00000000..44b5dbec --- /dev/null +++ b/apps/web/app/components/search/search-store.ts @@ -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, +): 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())}`; diff --git a/apps/web/app/components/search/search-trigger.tsx b/apps/web/app/components/search/search-trigger.tsx new file mode 100644 index 00000000..f4434a43 --- /dev/null +++ b/apps/web/app/components/search/search-trigger.tsx @@ -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 ( + <> + + + + ); +} diff --git a/apps/web/app/routes.ts b/apps/web/app/routes.ts index 51f260ad..be1e2b66 100644 --- a/apps/web/app/routes.ts +++ b/apps/web/app/routes.ts @@ -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"), diff --git a/apps/web/app/routes/console/layout.tsx b/apps/web/app/routes/console/layout.tsx index 24cb84db..60e7d073 100644 --- a/apps/web/app/routes/console/layout.tsx +++ b/apps/web/app/routes/console/layout.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 }) { {activeLabel}
    + {/* 桌面有侧栏主入口与右下角悬浮按钮,顶栏小按钮只在窄屏保留 */} @@ -275,6 +278,7 @@ function ConsoleShell({ session }: { session: SessionInfo }) { +
    ); diff --git a/apps/web/app/routes/console/search.tsx b/apps/web/app/routes/console/search.tsx new file mode 100644 index 00000000..530d4cfc --- /dev/null +++ b/apps/web/app/routes/console/search.tsx @@ -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 ( + <> + +
    +
    + + setInput(e.target.value)} + placeholder="作品名、别名或资源标题" + aria-label="搜索关键词" + className="pl-9" + /> +
    + +
    + + {!q ? ( + + ) : ( +
    + + + +
    + )} + + ); +} diff --git a/apps/web/scripts/smoke.mjs b/apps/web/scripts/smoke.mjs index 244bac3c..bb9174d6 100644 --- a/apps/web/scripts/smoke.mjs +++ b/apps/web/scripts/smoke.mjs @@ -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 属预期),再跳到下载详情