@@ -27,6 +31,8 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => (
)}
+
+ {searchEnabled &&
}
diff --git a/src/app/(app)/components/search-button.tsx b/src/app/(app)/components/search-button.tsx
new file mode 100644
index 0000000..e4f0d78
--- /dev/null
+++ b/src/app/(app)/components/search-button.tsx
@@ -0,0 +1,60 @@
+// 顶栏搜索按钮:打开搜索弹窗,并在按钮上显示后台搜索的进度与完成提示
+"use client";
+
+import { CheckIcon, SearchIcon, TriangleAlertIcon } from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import { cn } from "@/lib/utils";
+import { useSearchStore } from "@/stores/search-store";
+
+import { SearchDialog } from "./search-dialog";
+
+export const SearchButton = () => {
+ const status = useSearchStore((state) => state.status);
+ const total = useSearchStore((state) => state.total);
+ const completed = useSearchStore((state) => state.completed);
+ const unseen = useSearchStore((state) => state.unseen);
+ const setOpen = useSearchStore((state) => state.setOpen);
+
+ const searching = status === "searching";
+ // 完成/失败后一直保持到用户打开弹窗看过为止
+ const finished = unseen && status === "done";
+ const failed = unseen && status === "failed";
+
+ return (
+ <>
+
+
+ >
+ );
+};
diff --git a/src/app/(app)/components/search-dialog.tsx b/src/app/(app)/components/search-dialog.tsx
new file mode 100644
index 0000000..36efa5d
--- /dev/null
+++ b/src/app/(app)/components/search-dialog.tsx
@@ -0,0 +1,256 @@
+// 搜索弹窗:关键词、进度、失败站点与结果列表;关闭弹窗不会中断搜索(状态在 search-store)
+"use client";
+
+import { useState } from "react";
+import {
+ ChevronDownIcon,
+ ListFilterIcon,
+ SearchIcon,
+ TriangleAlertIcon
+} from "lucide-react";
+
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger
+} from "@/components/ui/collapsible";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle
+} from "@/components/ui/dialog";
+import {
+ Empty,
+ EmptyDescription,
+ EmptyHeader,
+ EmptyMedia,
+ EmptyTitle
+} from "@/components/ui/empty";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput
+} from "@/components/ui/input-group";
+import {
+ Progress,
+ ProgressLabel,
+ ProgressValue
+} from "@/components/ui/progress";
+import { Skeleton } from "@/components/ui/skeleton";
+import { MAX_QUERY_LENGTH } from "@/constants/search";
+import { useSearchResults } from "@/hooks/use-search-results";
+import { cn } from "@/lib/utils";
+import { useSearchStore, type SearchStatus } from "@/stores/search-store";
+import { useSearchViewStore } from "@/stores/search-view-store";
+
+import { SearchResultList } from "./search-result-list";
+import { SearchResultToolbar } from "./search-result-toolbar";
+
+/** 结果区固定高度:弹窗不随结果数量忽高忽低 */
+const RESULT_HEIGHT = "h-[min(40rem,60dvh)]";
+
+const progressLabel = (status: SearchStatus, resultCount: number) => {
+ switch (status) {
+ case "searching":
+ return `正在搜索 · 已有 ${resultCount} 条结果`;
+ case "done":
+ return `搜索完成 · 共 ${resultCount} 条结果`;
+ case "cancelled":
+ return `已取消 · 已有 ${resultCount} 条结果`;
+ default:
+ return `搜索失败 · 已有 ${resultCount} 条结果`;
+ }
+};
+
+const SearchForm = () => {
+ const status = useSearchStore((state) => state.status);
+ const start = useSearchStore((state) => state.start);
+ const cancel = useSearchStore((state) => state.cancel);
+ // 弹窗每次打开都会重新挂载,从最近一次搜索的关键词继续
+ const [draft, setDraft] = useState(() => useSearchStore.getState().query);
+ const keyword = draft.trim();
+
+ return (
+
+ );
+};
+
+const SearchProgress = () => {
+ const status = useSearchStore((state) => state.status);
+ const total = useSearchStore((state) => state.total);
+ const completed = useSearchStore((state) => state.completed);
+ const resultCount = useSearchStore((state) => state.results.length);
+ const failures = useSearchStore((state) => state.failures);
+ const error = useSearchStore((state) => state.error);
+
+ if (status === "idle") return null;
+
+ return (
+
+
+
+ {status === "failed" && error && (
+
+
+ {error}
+
+ )}
+
+ {failures.length > 0 && (
+
+
+ {failures.length} 个站点没有返回结果
+
+
+
+
+ {failures.map((failure) => (
+ -
+ {failure.indexerName} — {failure.error}
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+const SearchResults = () => {
+ const status = useSearchStore((state) => state.status);
+ const results = useSearchStore((state) => state.results);
+ const sortKey = useSearchViewStore((state) => state.sortKey);
+ const order = useSearchViewStore((state) => state.order);
+ const setKeyword = useSearchViewStore((state) => state.setKeyword);
+ const { items, terms } = useSearchResults(results);
+
+ if (results.length > 0)
+ return (
+
+
+ {items.length > 0 ? (
+
+ ) : (
+
+
+
+
+
+ 没有匹配的结果
+
+ 共 {results.length} 条结果中没有同时包含这些词的
+
+
+
+
+ )}
+
+ );
+
+ if (status === "searching")
+ return (
+
+ {Array.from({ length: 10 }, (_, index) => (
+
+ ))}
+
+ );
+
+ const idle = status === "idle";
+ return (
+
+
+
+
+
+ {idle ? "输入关键词开始搜索" : "没有找到结果"}
+ {idle ? "" : "换个关键词试试"}
+
+
+ );
+};
+
+export const SearchDialog = () => {
+ const open = useSearchStore((state) => state.open);
+ const setOpen = useSearchStore((state) => state.setOpen);
+
+ return (
+
+ );
+};
diff --git a/src/app/(app)/components/search-result-list.tsx b/src/app/(app)/components/search-result-list.tsx
new file mode 100644
index 0000000..b788c80
--- /dev/null
+++ b/src/app/(app)/components/search-result-list.tsx
@@ -0,0 +1,226 @@
+// 搜索结果列表(虚拟滚动):结果可达数千条,行高固定,只渲染可见行
+"use client";
+
+import { memo, useState } from "react";
+import {
+ ArrowDownIcon,
+ ArrowUpIcon,
+ CheckIcon,
+ CloudDownloadIcon,
+ CopyIcon,
+ ExternalLinkIcon,
+ type LucideIcon
+} from "lucide-react";
+
+import { HighlightText } from "@/components/highlight-text";
+import { Badge } from "@/components/ui/badge";
+import { Button } from "@/components/ui/button";
+import { Spinner } from "@/components/ui/spinner";
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger
+} from "@/components/ui/tooltip";
+import { VirtualList } from "@/components/virtual-list";
+import { useCopy } from "@/hooks/use-copy";
+import { useAddMagnet } from "@/hooks/use-torrents";
+import { cn } from "@/lib/utils";
+import { useSearchStore } from "@/stores/search-store";
+import type { SearchResult } from "@/types/search";
+import { formatRelativeDate } from "@/utils/date";
+import { formatBytes, formatCount } from "@/utils/format";
+
+/** 名称完整换行显示,行高不固定;这是未测量前的估计值(日期行 + 单行名称 + 底栏) */
+const ESTIMATED_ROW_HEIGHT = 108;
+
+type RowActionProps = {
+ label: string;
+ icon: LucideIcon;
+ /** 进行中:显示 Spinner 并禁用 */
+ pending?: boolean;
+ disabled?: boolean;
+ /** 链接型操作:在新标签页打开 */
+ href?: string;
+ onClick?: () => void;
+ className?: string;
+};
+
+/** 行尾的图标操作:只显示图标,名称放在 Tooltip 与 aria-label */
+const RowAction = ({
+ label,
+ icon: Icon,
+ pending = false,
+ disabled = false,
+ href,
+ onClick,
+ className
+}: RowActionProps) => (
+
+
+ )
+ }
+ : {})}
+ />
+ }
+ >
+ {pending ? : }
+
+ {label}
+
+);
+
+/** 每行都能跳转到站点;有 magnet 时另有复制、添加到 TorBox */
+const ResultActions = ({ result }: { result: SearchResult }) => {
+ const copy = useCopy();
+ const status = useSearchStore((state) => state.torrentStatus[result.id]);
+ const setTorrentStatus = useSearchStore((state) => state.setTorrentStatus);
+ const { mutate: addToTorbox } = useAddMagnet({
+ onMutate: () => setTorrentStatus(result.id, "adding"),
+ onSuccess: () => setTorrentStatus(result.id, "added"),
+ onError: () => setTorrentStatus(result.id)
+ });
+ const { magnet, detailsUrl } = result;
+
+ return (
+ <>
+ {magnet && (
+ <>
+
copy(magnet, "已复制 magnet 链接")}
+ />
+ addToTorbox(magnet)}
+ />
+ >
+ )}
+ {detailsUrl && (
+
+ )}
+ >
+ );
+};
+
+/** 做种 / 下载数:只显示图标与数字,名称放在 title 与读屏文字里 */
+const Stat = ({
+ label,
+ icon: Icon,
+ value
+}: {
+ label: string;
+ icon: LucideIcon;
+ value: number;
+}) => (
+
+
+ {label}
+ {formatCount(value)}
+
+);
+
+const ResultRow = memo(function ResultRow({
+ result,
+ terms,
+ now
+}: {
+ result: SearchResult;
+ terms: readonly string[];
+ now: number;
+}) {
+ const { publishedAt, size, seeders, leechers } = result;
+
+ return (
+
+
+ {/* 站点没给发布时间时留空,来源仍靠右 */}
+
+ {publishedAt ? formatRelativeDate(publishedAt, now) : null}
+
+
+ {result.indexerName}
+
+
+ {/* 名称不省略;没有空格的超长名称也要能在容器内断行 */}
+
+
+
+
+
+ {size != null && {formatBytes(size)}}
+ {seeders != null && (
+
+ )}
+ {leechers != null && (
+
+ )}
+
+ {/* 窄屏换到下一行,靠右 */}
+
+
+
+
+
+ );
+});
+
+type SearchResultListProps = {
+ /** 已筛选、排序好的结果 */
+ items: SearchResult[];
+ /** 高亮的筛选词 */
+ terms: readonly string[];
+ /** 筛选或排序条件变化时滚回顶部 */
+ resetScrollKey: string;
+ className?: string;
+};
+
+export const SearchResultList = ({
+ items,
+ terms,
+ resetScrollKey,
+ className
+}: SearchResultListProps) => {
+ // 相对日期以列表挂载时刻为「现在」;放在这里是为了不让每一行各自取时间
+ const [now] = useState(() => Date.now());
+
+ return (
+ result.id}
+ estimateSize={ESTIMATED_ROW_HEIGHT}
+ renderItem={(result) => (
+
+ )}
+ resetScrollKey={resetScrollKey}
+ className={className}
+ />
+ );
+};
diff --git a/src/app/(app)/components/search-result-toolbar.tsx b/src/app/(app)/components/search-result-toolbar.tsx
new file mode 100644
index 0000000..d63e4bb
--- /dev/null
+++ b/src/app/(app)/components/search-result-toolbar.tsx
@@ -0,0 +1,156 @@
+// 搜索结果工具栏:在已有结果里筛选,以及排序方式与方向
+"use client";
+
+import {
+ ArrowDownIcon,
+ ArrowDownUpIcon,
+ ArrowUpIcon,
+ ListFilterIcon,
+ XIcon
+} from "lucide-react";
+
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuGroup,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger
+} from "@/components/ui/dropdown-menu";
+import {
+ InputGroup,
+ InputGroupAddon,
+ InputGroupButton,
+ InputGroupInput,
+ InputGroupText
+} from "@/components/ui/input-group";
+import { SEARCH_SORT_OPTIONS } from "@/constants/search";
+import { useSearchViewStore } from "@/stores/search-view-store";
+import type { SearchSortKey, SortOrder } from "@/types/search";
+
+const ORDER_OPTIONS: { value: SortOrder; label: string }[] = [
+ { value: "asc", label: "升序" },
+ { value: "desc", label: "降序" }
+];
+
+const SortMenu = () => {
+ const sortKey = useSearchViewStore((state) => state.sortKey);
+ const order = useSearchViewStore((state) => state.order);
+ const setSortKey = useSearchViewStore((state) => state.setSortKey);
+ const setOrder = useSearchViewStore((state) => state.setOrder);
+ const label = SEARCH_SORT_OPTIONS.find(
+ (option) => option.value === sortKey
+ )?.label;
+ const orderLabel = ORDER_OPTIONS.find(
+ (option) => option.value === order
+ )?.label;
+
+ return (
+
+
+ }
+ >
+
+ {label}
+ {order === "asc" ? (
+
+ ) : (
+
+ )}
+
+
+
+ 排序方式
+ setSortKey(value)}
+ >
+ {SEARCH_SORT_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ 顺序
+ setOrder(value)}
+ >
+ {ORDER_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+
+ );
+};
+
+type SearchResultToolbarProps = {
+ /** 本次搜索的结果总数 */
+ total: number;
+ /** 筛选后剩余的条数 */
+ shown: number;
+};
+
+export const SearchResultToolbar = ({
+ total,
+ shown
+}: SearchResultToolbarProps) => {
+ const keyword = useSearchViewStore((state) => state.keyword);
+ const setKeyword = useSearchViewStore((state) => state.setKeyword);
+
+ return (
+
+
+
+
+
+ setKeyword(event.target.value)}
+ placeholder="在结果中筛选,空格分隔多个词"
+ aria-label="在结果中筛选"
+ autoComplete="off"
+ />
+ {keyword && (
+
+
+ {shown} / {total}
+
+ setKeyword("")}
+ >
+
+
+
+ )}
+
+
+
+ );
+};
diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx
index 5a03a43..2983417 100644
--- a/src/app/(app)/layout.tsx
+++ b/src/app/(app)/layout.tsx
@@ -1,5 +1,6 @@
// 登录后页面布局
import { isMockMode } from "@/server/env";
+import { isSearchAvailable } from "@/server/search";
import { requireSessionToken } from "@/server/session";
import { AppHeader } from "./components/app-header";
@@ -10,7 +11,11 @@ const AppLayout = async ({ children }: { children: React.ReactNode }) => {
return (
);
diff --git a/src/app/api/search/route.ts b/src/app/api/search/route.ts
new file mode 100644
index 0000000..2144173
--- /dev/null
+++ b/src/app/api/search/route.ts
@@ -0,0 +1,50 @@
+// 搜索接口:并行查询全部 Indexer,以 NDJSON 流逐个交付结果
+import type { NextRequest } from "next/server";
+import { MAX_QUERY_LENGTH } from "@/constants/search";
+import { guard } from "@/server/auth";
+import { JackettError } from "@/server/jackett";
+import { createLogger } from "@/server/logger";
+import { jsonError, ndjsonResponse, withApi } from "@/server/response";
+import {
+ getSearchIndexers,
+ isSearchAvailable,
+ runSearch
+} from "@/server/search";
+import type { SearchEvent } from "@/types/search";
+
+const log = createLogger("search");
+
+export const dynamic = "force-dynamic";
+
+export const GET = withApi(async (req: NextRequest) => {
+ const denied = guard(req);
+ if (denied) return denied;
+ if (!isSearchAvailable()) return jsonError(503, "未配置 Jackett");
+
+ const query = req.nextUrl.searchParams.get("q")?.trim() ?? "";
+ if (!query) return jsonError(400, "请输入搜索关键词");
+ if (query.length > MAX_QUERY_LENGTH)
+ return jsonError(400, `关键词不能超过 ${MAX_QUERY_LENGTH} 个字符`);
+
+ // 先取 Indexer 列表:失败时还没开始写流,可以返回正常的错误响应
+ let indexers;
+ try {
+ indexers = await getSearchIndexers(req.signal);
+ } catch (error) {
+ log.error({ err: error }, "获取 Indexer 列表失败");
+ return jsonError(
+ 502,
+ error instanceof JackettError
+ ? `Jackett 返回错误:${error.message}`
+ : "无法连接 Jackett"
+ );
+ }
+ if (indexers.length === 0)
+ return jsonError(404, "Jackett 中没有已配置的 Indexer");
+
+ return ndjsonResponse(req, async (send, signal) => {
+ send({ type: "start", total: indexers.length });
+ await runSearch(indexers, query, signal, send);
+ send({ type: "done" });
+ });
+});
diff --git a/src/app/api/torrents/route.ts b/src/app/api/torrents/route.ts
new file mode 100644
index 0000000..1a0d329
--- /dev/null
+++ b/src/app/api/torrents/route.ts
@@ -0,0 +1,35 @@
+// 添加 torrent 接口(magnet)
+import type { NextRequest } from "next/server";
+import { MAX_MAGNET_LENGTH } from "@/constants/torbox";
+import { guard } from "@/server/auth";
+import { createLogger } from "@/server/logger";
+import { jsonError, jsonOk, withApi } from "@/server/response";
+import { addMagnet } from "@/server/torbox";
+import { isMagnetLink } from "@/utils/magnet";
+
+const log = createLogger("torrents");
+
+export const dynamic = "force-dynamic";
+
+export const POST = withApi(async (req: NextRequest) => {
+ const denied = guard(req);
+ if (denied) return denied;
+
+ const body = await req.json().catch(() => ({}));
+ const magnet = typeof body.magnet === "string" ? body.magnet.trim() : "";
+ if (!magnet || magnet.length > MAX_MAGNET_LENGTH || !isMagnetLink(magnet))
+ return jsonError(400, "magnet 链接格式不正确");
+
+ try {
+ const result = await addMagnet(magnet);
+ log.info({ id: result.id, hash: result.hash }, "已添加 magnet");
+ return jsonOk(result);
+ } catch (error) {
+ // TorBox 的失败原因(名额已满、限流等)对用户有意义,原样透出
+ log.error({ err: error }, "添加 magnet 失败");
+ return jsonError(
+ 502,
+ error instanceof Error ? error.message : "upstream error"
+ );
+ }
+});
diff --git a/src/components/highlight-text.tsx b/src/components/highlight-text.tsx
new file mode 100644
index 0000000..a3cc6c0
--- /dev/null
+++ b/src/components/highlight-text.tsx
@@ -0,0 +1,38 @@
+// 高亮文本中命中的关键词(不区分大小写)
+import { Fragment } from "react";
+
+const escapeRegExp = (value: string) =>
+ value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+
+type HighlightTextProps = {
+ text: string;
+ /** 要高亮的词;为空时原样输出 */
+ terms: readonly string[];
+};
+
+export const HighlightText = ({ text, terms }: HighlightTextProps) => {
+ if (terms.length === 0) return text;
+
+ // 长词优先,避免「ubuntu」被「ub」抢先匹配后只亮一半
+ const pattern = [...terms]
+ .sort((a, b) => b.length - a.length)
+ .map(escapeRegExp)
+ .join("|");
+ // 带捕获组的 split:奇数下标的片段就是命中的部分
+ const parts = text.split(new RegExp(`(${pattern})`, "i"));
+
+ return parts.map((part, index) =>
+ index % 2 === 1 ? (
+ // 实心翠绿 + 深墨字(对比度 8.2:1);半透明底在深色卡片上几乎看不出来。
+ // box-decoration-clone:命中词跨行断开时,每一段都保留完整的圆角与内边距
+
+ {part}
+
+ ) : (
+ {part}
+ )
+ );
+};
diff --git a/src/components/image-lightbox.tsx b/src/components/image-lightbox.tsx
index 817c52e..e57925e 100644
--- a/src/components/image-lightbox.tsx
+++ b/src/components/image-lightbox.tsx
@@ -94,6 +94,8 @@ const SINGLE_RENDER: Render = {
/** 灯箱配色映射到现有主题变量,不新增颜色 */
const STYLES: SlotStyles = {
root: {
+ // 层级:浮层 z-50 < 灯箱 < 通知 z-100;库默认 9999 会盖住通知
+ "--yarl__portal_zindex": 90,
"--yarl__color_backdrop": "var(--background)",
"--yarl__color_button": "var(--muted-foreground)",
"--yarl__color_button_active": "var(--foreground)",
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx
index f200244..f292e66 100644
--- a/src/components/ui/toast.tsx
+++ b/src/components/ui/toast.tsx
@@ -29,7 +29,9 @@ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
= {
+ items: readonly T[];
+ /** 稳定的行标识;重新排序后已测量的行高会跟着行走 */
+ getItemKey: (item: T) => string | number;
+ /** 未测量前的估计行高(px),越接近实际值滚动条越稳 */
+ estimateSize: number;
+ renderItem: (item: T) => ReactNode;
+ overscan?: number;
+ /** 值变化时滚回顶部,如筛选、排序条件改变;流式追加数据不应改变它 */
+ resetScrollKey?: string;
+ className?: string;
+};
+
+export const VirtualList = ({
+ items,
+ getItemKey,
+ estimateSize,
+ renderItem,
+ overscan = 8,
+ resetScrollKey,
+ className
+}: VirtualListProps) => {
+ const scrollRef = useRef(null);
+
+ // eslint-disable-next-line react-hooks/incompatible-library -- 项目未启用 React Compiler;虚拟器每次渲染都需读取最新滚动状态
+ const virtualizer = useVirtualizer({
+ count: items.length,
+ getScrollElement: () => scrollRef.current,
+ getItemKey: (index) => getItemKey(items[index]),
+ estimateSize: () => estimateSize,
+ overscan
+ });
+
+ useEffect(() => {
+ scrollRef.current?.scrollTo({ top: 0 });
+ }, [resetScrollKey]);
+
+ const rows = virtualizer.getVirtualItems();
+ const top = rows[0]?.start ?? 0;
+ const bottom = virtualizer.getTotalSize() - (rows.at(-1)?.end ?? 0);
+
+ return (
+
+
+ {rows.map((row) => (
+ // measureElement 读取 data-index 并监听尺寸变化
+
+ {renderItem(items[row.index])}
+
+ ))}
+
+
+ );
+};
diff --git a/src/constants/search.ts b/src/constants/search.ts
new file mode 100644
index 0000000..7ffba3f
--- /dev/null
+++ b/src/constants/search.ts
@@ -0,0 +1,28 @@
+// 搜索相关常量
+import type { SearchSortKey, SortOrder } from "@/types/search";
+
+export const SEARCH_API = "/search";
+
+/** 同时请求的 Indexer 数量上限 */
+export const SEARCH_CONCURRENCY = 12;
+
+/** 单个 Indexer 的超时:实测多数 3 秒内返回,少数要 10–20 秒,其余会卡到 Jackett 自身的 60 秒 */
+export const INDEXER_TIMEOUT_MS = 30_000;
+
+/** Indexer 列表缓存时长,配置变更后最多这么久生效 */
+export const INDEXER_CACHE_MS = 5 * 60 * 1000;
+
+export const MAX_QUERY_LENGTH = 100;
+
+/** defaultOrder:切换到该排序方式时的初始方向(名称从 A 到 Z,其余数值大的在前) */
+export const SEARCH_SORT_OPTIONS: {
+ value: SearchSortKey;
+ label: string;
+ defaultOrder: SortOrder;
+}[] = [
+ { value: "name", label: "名称", defaultOrder: "asc" },
+ { value: "seeders", label: "做种者", defaultOrder: "desc" },
+ { value: "leechers", label: "下载者", defaultOrder: "desc" },
+ { value: "size", label: "文件大小", defaultOrder: "desc" },
+ { value: "date", label: "日期", defaultOrder: "desc" }
+];
diff --git a/src/constants/torbox.ts b/src/constants/torbox.ts
index 52cc1e8..f6457fe 100644
--- a/src/constants/torbox.ts
+++ b/src/constants/torbox.ts
@@ -25,6 +25,9 @@ export const SOURCES = [
}
] as const;
+/** magnet 含 tracker 与名称,长度有限;超出视为异常输入(前后端共用) */
+export const MAX_MAGNET_LENGTH = 8192;
+
export const IMAGE_EXT = new Set([
"jpg",
"jpeg",
diff --git a/src/hooks/use-search-results.ts b/src/hooks/use-search-results.ts
new file mode 100644
index 0000000..1958b0f
--- /dev/null
+++ b/src/hooks/use-search-results.ts
@@ -0,0 +1,57 @@
+// 搜索结果的筛选与排序
+import { useDeferredValue, useMemo } from "react";
+
+import { useSearchViewStore } from "@/stores/search-view-store";
+import type { SearchResult, SearchSortKey, SortOrder } from "@/types/search";
+import { toTimestamp } from "@/utils/date";
+import { compareByNumber, compareNatural, type Comparator } from "@/utils/sort";
+
+/** 缺少字段的结果始终排在最后,与方向无关 */
+const getComparator = (
+ key: SearchSortKey,
+ order: SortOrder
+): Comparator => {
+ switch (key) {
+ case "name":
+ return (a, b) =>
+ (order === "asc" ? 1 : -1) * compareNatural(a.title, b.title);
+ case "seeders":
+ return compareByNumber((result) => result.seeders, order);
+ case "leechers":
+ return compareByNumber((result) => result.leechers, order);
+ case "size":
+ return compareByNumber((result) => result.size, order);
+ case "date":
+ return compareByNumber(
+ (result) => toTimestamp(result.publishedAt),
+ order
+ );
+ }
+};
+
+/** 筛选并排序后的结果;terms 是筛选词(小写),供列表高亮命中的部分 */
+export const useSearchResults = (results: SearchResult[]) => {
+ const keyword = useSearchViewStore((state) => state.keyword);
+ const sortKey = useSearchViewStore((state) => state.sortKey);
+ const order = useSearchViewStore((state) => state.order);
+ // 输入时先让输入框响应,几千条结果的筛选稍后再算
+ const deferredKeyword = useDeferredValue(keyword);
+
+ const terms = useMemo(
+ () => deferredKeyword.toLowerCase().split(/\s+/).filter(Boolean),
+ [deferredKeyword]
+ );
+
+ const items = useMemo(() => {
+ const matched = terms.length
+ ? results.filter((result) => {
+ const title = result.title.toLowerCase();
+ return terms.every((term) => title.includes(term));
+ })
+ : [...results];
+ // 稳定排序:相等时保留结果到达的顺序
+ return matched.sort(getComparator(sortKey, order));
+ }, [results, terms, sortKey, order]);
+
+ return { items, terms };
+};
diff --git a/src/hooks/use-torrents.ts b/src/hooks/use-torrents.ts
new file mode 100644
index 0000000..d16d2e3
--- /dev/null
+++ b/src/hooks/use-torrents.ts
@@ -0,0 +1,36 @@
+// torrent 相关 hook
+import { useMutation } from "@tanstack/react-query";
+
+import { useRefreshLibrary } from "@/hooks/use-library";
+import { message } from "@/lib/message";
+import { addMagnet } from "@/services/torrents";
+
+type UseAddMagnetOptions = {
+ onMutate?: () => void;
+ onSuccess?: () => void;
+ onError?: () => void;
+};
+
+/**
+ * 添加 magnet 到 TorBox:成功后提示并刷新资源库,失败提示由 axios 拦截器统一处理。
+ * 回调通过 hook 参数传入而不是 mutate():mutate() 的回调在组件卸载后不会触发,
+ * 而调用方(虚拟滚动的结果行、已关闭的弹窗)随时可能卸载,状态仍要正确收尾。
+ */
+export const useAddMagnet = ({
+ onMutate,
+ onSuccess,
+ onError
+}: UseAddMagnetOptions = {}) => {
+ const refreshLibrary = useRefreshLibrary();
+ return useMutation({
+ mutationFn: addMagnet,
+ onMutate: () => onMutate?.(),
+ onSuccess: () => {
+ message.success("已添加到 TorBox");
+ // TorBox 的 torrent 列表默认 600 秒才更新,新添加的资源要绕过缓存才能看到
+ void refreshLibrary();
+ onSuccess?.();
+ },
+ onError: () => onError?.()
+ });
+};
diff --git a/src/server/env.ts b/src/server/env.ts
index dfedbac..e4e831c 100644
--- a/src/server/env.ts
+++ b/src/server/env.ts
@@ -4,6 +4,8 @@ import { MOCK_ACCESS_TOKEN } from "@/constants/auth";
type ServerEnvName =
| "ACCESS_TOKEN"
| "TORBOX_API_KEY"
+ | "JACKETT_URL"
+ | "JACKETT_API_KEY"
| "LOG_LEVEL"
| "LOG_DIR"
| "LOG_RETAIN_DAYS";
@@ -51,6 +53,24 @@ export const getTorboxApiKey = () => {
return key;
};
+/** Jackett 是否已配置(地址与 API Key 缺一不可);未配置时搜索不可用 */
+export const isJackettConfigured = () =>
+ Boolean(readEnv("JACKETT_URL") && readEnv("JACKETT_API_KEY"));
+
+/** Jackett 地址,去掉末尾斜杠,如 http://192.168.1.10:9117 */
+export const getJackettUrl = () => {
+ const url = readEnv("JACKETT_URL")?.trim().replace(/\/+$/, "");
+ if (!url) throw new Error("未配置 JACKETT_URL");
+ return url;
+};
+
+/** Jackett API Key,仅服务端使用 */
+export const getJackettApiKey = () => {
+ const key = readEnv("JACKETT_API_KEY");
+ if (!key) throw new Error("未配置 JACKETT_API_KEY");
+ return key;
+};
+
/** 日志级别(LOG_LEVEL),未配置时由日志模块按运行环境决定 */
export const getLogLevel = () => readEnv("LOG_LEVEL")?.trim().toLowerCase();
@@ -65,6 +85,8 @@ export const getLogRetainDays = () => {
/** 实际配置的密钥值,供日志脱敏使用;演示模式的固定密钥不是机密,不在其中 */
export const getSecretValues = () =>
- [readEnv("ACCESS_TOKEN"), readEnv("TORBOX_API_KEY")].filter(
- (value): value is string => Boolean(value)
- );
+ [
+ readEnv("ACCESS_TOKEN"),
+ readEnv("TORBOX_API_KEY"),
+ readEnv("JACKETT_API_KEY")
+ ].filter((value): value is string => Boolean(value));
diff --git a/src/server/jackett.ts b/src/server/jackett.ts
new file mode 100644
index 0000000..e72acb6
--- /dev/null
+++ b/src/server/jackett.ts
@@ -0,0 +1,149 @@
+// Jackett(Torznab)服务端封装
+//
+// 请求地址带 API Key,所以:
+// - 日志只记 Indexer id,不记 URL
+// - 结果只读取白名单字段;`link`、`enclosure` 是 Jackett 的代理下载地址,含 API Key,绝不下发
+import { XMLParser } from "fast-xml-parser";
+
+import { INDEXER_CACHE_MS } from "@/constants/search";
+import { getJackettApiKey, getJackettUrl } from "@/server/env";
+import type { SearchResult } from "@/types/search";
+
+export type Indexer = { id: string; name: string };
+
+/** Jackett 报告的错误(HTTP 200 + ,或 4xx 带 ) */
+export class JackettError extends Error {}
+
+const ARRAY_TAGS = new Set(["indexer", "item", "torznab:attr"]);
+
+const parser = new XMLParser({
+ ignoreAttributes: false,
+ attributeNamePrefix: "@_",
+ // 标题、id 保持原样,不转成数字
+ parseTagValue: false,
+ parseAttributeValue: false,
+ isArray: (name) => ARRAY_TAGS.has(name)
+});
+
+type RawXml = Record; // eslint-disable-line @typescript-eslint/no-explicit-any
+
+/** Jackett 的错误描述含整段堆栈甚至 HTML,只保留第一句 */
+const summarizeError = (description: unknown) => {
+ const text = String(description ?? "")
+ .replace(/^[\s\S]*?Exception \([^)]*\):\s*/, "")
+ .split(/\s+Response=|\s+--->|\r?\n|[0-9a-f]+;/i)[0]
+ .trim();
+ return text.slice(0, 120) || "未知错误";
+};
+
+const jackettFetch = async (
+ path: string,
+ params: URLSearchParams,
+ signal: AbortSignal
+) => {
+ params.set("apikey", getJackettApiKey());
+ const res = await fetch(
+ `${getJackettUrl()}/api/v2.0/indexers/${path}/results/torznab/api?${params}`,
+ {
+ signal,
+ cache: "no-store"
+ }
+ );
+ const doc: RawXml = parser.parse(await res.text());
+ if (doc.error)
+ throw new JackettError(summarizeError(doc.error["@_description"]));
+ if (!res.ok) throw new JackettError(`HTTP ${res.status}`);
+ return doc;
+};
+
+let indexerCache: { at: number; data: Promise } | null = null;
+
+/** 已配置的 Indexer;缓存的是 Promise,并发请求共用同一次拉取,失败不缓存 */
+export const listIndexers = (signal: AbortSignal) => {
+ if (indexerCache && Date.now() - indexerCache.at < INDEXER_CACHE_MS)
+ return indexerCache.data;
+ const data = jackettFetch(
+ "all",
+ new URLSearchParams({ t: "indexers", configured: "true" }),
+ signal
+ ).then((doc): Indexer[] =>
+ (doc.indexers?.indexer ?? []).map((indexer: RawXml) => ({
+ id: indexer["@_id"],
+ name: indexer.title ?? indexer["@_id"]
+ }))
+ );
+ const entry = { at: Date.now(), data };
+ indexerCache = entry;
+ data.catch(() => {
+ if (indexerCache === entry) indexerCache = null;
+ });
+ return data;
+};
+
+const toNumber = (value: unknown) => {
+ const number = Number(value);
+ return value != null && value !== "" && Number.isFinite(number)
+ ? number
+ : null;
+};
+
+const toIso = (value: unknown) => {
+ const time = Date.parse(String(value ?? ""));
+ return Number.isNaN(time) ? null : new Date(time).toISOString();
+};
+
+const toResult = (item: RawXml, indexer: Indexer): SearchResult | null => {
+ const title = String(item.title ?? "").trim();
+ if (!title) return null;
+
+ const attrs = new Map(
+ (item["torznab:attr"] ?? []).map((attr: RawXml) => [
+ attr["@_name"],
+ attr["@_value"]
+ ])
+ );
+ const infohash = attrs.get("infohash")?.toLowerCase() || null;
+ const link = String(item.link ?? "");
+ const direct = [attrs.get("magneturl"), link].find((url) =>
+ url?.startsWith("magnet:")
+ );
+ const magnet =
+ direct ??
+ (infohash
+ ? `magnet:?xt=urn:btih:${infohash}&dn=${encodeURIComponent(title)}`
+ : null);
+ const comments = String(item.comments ?? "");
+ const seeders = toNumber(attrs.get("seeders"));
+ const peers = toNumber(attrs.get("peers"));
+ const size = toNumber(item.size);
+
+ return {
+ id: infohash ?? `${indexer.id}:${item.guid ?? title}`,
+ title,
+ indexerId: indexer.id,
+ indexerName: indexer.name,
+ // 有的站点(如 Uindex)不提供大小,Jackett 填 0;真实的种子不会是 0 字节,按未知处理
+ size: size && size > 0 ? size : null,
+ seeders,
+ leechers:
+ seeders != null && peers != null ? Math.max(0, peers - seeders) : null,
+ publishedAt: toIso(item.pubDate),
+ detailsUrl: /^https?:\/\//.test(comments) ? comments : null,
+ magnet
+ };
+};
+
+/** 单个 Indexer 的搜索结果;失败(站点出错、超时)以异常抛出 */
+export const queryIndexer = async (
+ indexer: Indexer,
+ query: string,
+ signal: AbortSignal
+): Promise => {
+ const doc = await jackettFetch(
+ indexer.id,
+ new URLSearchParams({ t: "search", q: query }),
+ signal
+ );
+ const items: RawXml[] = doc.rss?.channel?.item ?? [];
+ return items.flatMap((item) => toResult(item, indexer) ?? []);
+};
diff --git a/src/server/mock-search.ts b/src/server/mock-search.ts
new file mode 100644
index 0000000..8c3bf44
--- /dev/null
+++ b/src/server/mock-search.ts
@@ -0,0 +1,88 @@
+// 演示模式的模拟搜索:几个速度、结果数、成败各异的 Indexer,用来观察流式进度
+import type { Indexer } from "@/server/jackett";
+import type { SearchResult } from "@/types/search";
+
+const MB = 1024 ** 2;
+
+type MockIndexer = Indexer & {
+ delayMs: number;
+ count: number;
+ /** 有值则该 Indexer 失败 */
+ error?: string;
+};
+
+const MOCK_INDEXERS: MockIndexer[] = [
+ { id: "mock-fast", name: "Fast Tracker", delayMs: 400, count: 8 },
+ { id: "mock-medium", name: "Medium Index", delayMs: 1500, count: 14 },
+ { id: "mock-empty", name: "Empty Site", delayMs: 900, count: 0 },
+ {
+ id: "mock-blocked",
+ name: "Blocked Site",
+ delayMs: 1100,
+ count: 0,
+ error: "Error Parsing Json Response: Status=Forbidden"
+ },
+ { id: "mock-slow", name: "Slow Archive", delayMs: 5000, count: 20 },
+ { id: "mock-big", name: "Big Pile", delayMs: 2500, count: 30 },
+ { id: "mock-late", name: "Late Mirror", delayMs: 9000, count: 6 }
+];
+
+export const getMockIndexers = (): Indexer[] =>
+ MOCK_INDEXERS.map(({ id, name }) => ({ id, name }));
+
+const QUALITIES = [
+ "2160p UHD BluRay x265",
+ "1080p WEB-DL H264",
+ "720p HDTV x264",
+ "1080p BluRay REMUX"
+];
+
+const hex = (seed: number) =>
+ Array.from({ length: 40 }, (_, i) =>
+ ((seed * 31 + i * 17) % 16).toString(16)
+ ).join("");
+
+export const queryMockIndexer = (
+ indexer: Indexer,
+ query: string,
+ signal: AbortSignal
+): Promise =>
+ new Promise((resolve, reject) => {
+ const mock = MOCK_INDEXERS.find((item) => item.id === indexer.id)!;
+ const timer = setTimeout(() => {
+ if (mock.error) return reject(new Error(mock.error));
+ resolve(
+ Array.from({ length: mock.count }, (_, index) => {
+ const seed = mock.delayMs + index;
+ const title = `${query} ${QUALITIES[index % QUALITIES.length]}`;
+ const infohash = hex(seed);
+ return {
+ id: infohash,
+ title,
+ indexerId: mock.id,
+ indexerName: mock.name,
+ size: (300 + ((seed * 7919) % 6000)) * MB,
+ seeders: (seed * 13) % 400,
+ leechers: (seed * 17) % 90,
+ publishedAt: new Date(
+ Date.now() - (index + 1) * 36 * 3600 * 1000
+ ).toISOString(),
+ detailsUrl: `https://example.com/torrent/${infohash}`,
+ // 一部分结果模拟「站点未直接给出 magnet」
+ magnet:
+ index % 3 === 2
+ ? null
+ : `magnet:?xt=urn:btih:${infohash}&dn=${encodeURIComponent(title)}`
+ };
+ })
+ );
+ }, mock.delayMs);
+ signal.addEventListener(
+ "abort",
+ () => {
+ clearTimeout(timer);
+ reject(signal.reason);
+ },
+ { once: true }
+ );
+ });
diff --git a/src/server/response.ts b/src/server/response.ts
index 0a7dab1..88a1940 100644
--- a/src/server/response.ts
+++ b/src/server/response.ts
@@ -34,3 +34,45 @@ export const withApi =
return jsonError(500, "服务器内部错误");
}
};
+
+/**
+ * NDJSON 流式响应:每次 send 写出一行 JSON。
+ * - 客户端断开或取消读取时 signal 中止,run 应据此停止上游请求;run 结束后流自动关闭
+ * - no-transform 防止被压缩层攒包,X-Accel-Buffering 让 nginx 不缓冲
+ */
+export const ndjsonResponse = (
+ req: Request,
+ run: (send: (event: T) => void, signal: AbortSignal) => Promise
+) => {
+ const abort = new AbortController();
+ req.signal.addEventListener("abort", () => abort.abort(), { once: true });
+ const encoder = new TextEncoder();
+
+ const stream = new ReadableStream({
+ async start(controller) {
+ const send = (event: T) => {
+ if (!abort.signal.aborted)
+ controller.enqueue(encoder.encode(`${JSON.stringify(event)}\n`));
+ };
+ try {
+ await run(send, abort.signal);
+ } catch (error) {
+ if (!abort.signal.aborted)
+ log.error({ err: error }, "流式接口异常中断");
+ } finally {
+ if (!abort.signal.aborted) controller.close();
+ }
+ },
+ cancel() {
+ abort.abort();
+ }
+ });
+
+ return new Response(stream, {
+ headers: {
+ "Content-Type": "application/x-ndjson; charset=utf-8",
+ "Cache-Control": "no-store, no-transform",
+ "X-Accel-Buffering": "no"
+ }
+ });
+};
diff --git a/src/server/search.ts b/src/server/search.ts
new file mode 100644
index 0000000..f8ba43c
--- /dev/null
+++ b/src/server/search.ts
@@ -0,0 +1,103 @@
+// 并行搜索所有 Indexer,谁先返回谁先交付
+import { INDEXER_TIMEOUT_MS, SEARCH_CONCURRENCY } from "@/constants/search";
+import { isJackettConfigured, isMockMode } from "@/server/env";
+import { listIndexers, queryIndexer, type Indexer } from "@/server/jackett";
+import { createLogger } from "@/server/logger";
+import { getMockIndexers, queryMockIndexer } from "@/server/mock-search";
+import type { SearchEvent, SearchResult } from "@/types/search";
+
+const log = createLogger("search");
+
+/** 搜索是否可用:配置了 Jackett,或处于演示模式(使用模拟数据) */
+export const isSearchAvailable = () => isJackettConfigured() || isMockMode();
+
+/** 已配置 Jackett 时一律走真实接口,演示模式只在没有 Jackett 时兜底 */
+const shouldMock = () => !isJackettConfigured();
+
+export const getSearchIndexers = (signal: AbortSignal) =>
+ shouldMock() ? Promise.resolve(getMockIndexers()) : listIndexers(signal);
+
+const describeError = (error: unknown) => {
+ if (error instanceof DOMException && error.name === "TimeoutError")
+ return `超时(${INDEXER_TIMEOUT_MS / 1000} 秒)`;
+ return error instanceof Error ? error.message : "未知错误";
+};
+
+/**
+ * 以 SEARCH_CONCURRENCY 为上限并行查询,每个 Indexer 完成(成功或失败)就 emit 一个事件。
+ * signal 中止(用户取消或断开连接)后不再发起新请求,也不再 emit。
+ */
+export const runSearch = async (
+ indexers: Indexer[],
+ query: string,
+ signal: AbortSignal,
+ emit: (event: SearchEvent) => void
+) => {
+ const queryOne = shouldMock() ? queryMockIndexer : queryIndexer;
+ const seen = new Set();
+ const queue = [...indexers];
+ const startedAt = Date.now();
+ let resultCount = 0;
+ let failedCount = 0;
+
+ const worker = async () => {
+ for (let indexer = queue.shift(); indexer; indexer = queue.shift()) {
+ if (signal.aborted) return;
+ const indexerStartedAt = Date.now();
+ let results: SearchResult[] = [];
+ let error: string | undefined;
+ try {
+ results = await queryOne(
+ indexer,
+ query,
+ AbortSignal.any([signal, AbortSignal.timeout(INDEXER_TIMEOUT_MS)])
+ );
+ } catch (caught) {
+ if (signal.aborted) return;
+ error = describeError(caught);
+ }
+
+ // 不同站点常收录同一个种子,同一次搜索里只交付第一次出现的
+ const fresh = results.filter((result) => {
+ if (seen.has(result.id)) return false;
+ seen.add(result.id);
+ return true;
+ });
+ const durationMs = Date.now() - indexerStartedAt;
+ resultCount += fresh.length;
+ if (error) failedCount += 1;
+ log[error ? "warn" : "debug"](
+ { indexerId: indexer.id, durationMs, resultCount: fresh.length, error },
+ "Indexer 搜索完成"
+ );
+ emit({
+ type: "indexer",
+ indexerId: indexer.id,
+ indexerName: indexer.name,
+ durationMs,
+ results: fresh,
+ error
+ });
+ }
+ };
+
+ await Promise.all(
+ Array.from(
+ { length: Math.min(SEARCH_CONCURRENCY, indexers.length) },
+ worker
+ )
+ );
+ if (signal.aborted) {
+ log.info({ durationMs: Date.now() - startedAt }, "搜索已取消");
+ return;
+ }
+ log.info(
+ {
+ indexerCount: indexers.length,
+ failedCount,
+ resultCount,
+ durationMs: Date.now() - startedAt
+ },
+ "搜索完成"
+ );
+};
diff --git a/src/server/torbox.ts b/src/server/torbox.ts
index 2f90204..7abd070 100644
--- a/src/server/torbox.ts
+++ b/src/server/torbox.ts
@@ -4,6 +4,7 @@ import { createLogger } from "@/server/logger";
import { getMockLibrary } from "@/server/mock-library";
import { SOURCES, TORBOX_API } from "@/constants/torbox";
import type {
+ AddTorrentResult,
DownloadStatus,
LibraryItem,
LibraryResponse,
@@ -52,9 +53,11 @@ const apiKey = getTorboxApiKey;
const log = createLogger("torbox");
-const torboxFetch = async (path: string) => {
+const torboxFetch = async (path: string, init: RequestInit = {}) => {
const startedAt = Date.now();
+ // body 为 FormData 时不能手动设置 Content-Type,fetch 会自动带上 boundary
const res = await fetch(TORBOX_API + path, {
+ ...init,
headers: {
Authorization: `Bearer ${apiKey()}`,
Accept: "application/json"
@@ -177,6 +180,33 @@ export const getLibrary = async ({
return { items, errors };
};
+/** 从已校验的 magnet 中取出 info hash(小写) */
+const getMagnetHash = (magnet: string) =>
+ magnet.match(/xt=urn:btih:([0-9a-z]+)/i)?.[1].toLowerCase() ?? "";
+
+/**
+ * 通过 magnet 向 TorBox 添加 torrent。
+ * 使用同步的 createtorrent 而非 asynccreatetorrent:后者失败时不会在响应里返回错误,无法即时提示用户。
+ * 演示模式下不请求 TorBox,直接返回模拟结果(资源库的模拟数据不会随之变化)。
+ */
+export const addMagnet = async (magnet: string): Promise => {
+ if (isMockMode()) return { id: 0, hash: getMagnetHash(magnet) };
+
+ const form = new FormData();
+ form.set("magnet", magnet);
+ const data = await torboxFetch("/torrents/createtorrent", {
+ method: "POST",
+ body: form
+ });
+ if (typeof data?.torrent_id !== "number")
+ throw new Error("TorBox 未返回 torrent_id");
+ // auth_id 等内部字段不下发给客户端
+ return {
+ id: data.torrent_id,
+ hash: String(data.hash ?? getMagnetHash(magnet))
+ };
+};
+
const MOCK_PLAY_ERROR =
"演示模式没有可播放的文件,配置 TORBOX_API_KEY 后可播放真实资源";
diff --git a/src/services/search.ts b/src/services/search.ts
new file mode 100644
index 0000000..8c06889
--- /dev/null
+++ b/src/services/search.ts
@@ -0,0 +1,49 @@
+// 搜索 API 请求
+//
+// axios 在浏览器里无法边接收边读取响应体,流式接口只能用 fetch;
+// 401 与错误提示的处理要和 src/lib/axios.ts 的拦截器保持一致
+import { SEARCH_API } from "@/constants/search";
+import type { ApiResponse } from "@/types/api";
+import type { SearchEvent } from "@/types/search";
+
+const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "/api";
+
+/**
+ * 发起搜索并读取 NDJSON 流,每解析出一行就回调一次。
+ * 请求被拒绝(未配置、参数错误、Jackett 不可用等)时抛出带服务端提示的 Error;
+ * 中止 signal 时抛出 AbortError。
+ */
+export const streamSearch = async (
+ query: string,
+ signal: AbortSignal,
+ onEvent: (event: SearchEvent) => void
+) => {
+ const res = await fetch(
+ `${API_BASE}${SEARCH_API}?${new URLSearchParams({ q: query })}`,
+ { signal, cache: "no-store" }
+ );
+
+ if (res.status === 401) {
+ // eslint-disable-next-line @next/next/no-location-assign-relative-destination -- 不在 React 组件内,无法使用 router
+ window.location.assign("/login");
+ throw new Error("登录已失效");
+ }
+ if (!res.ok || !res.body) {
+ const body: ApiResponse | null = await res.json().catch(() => null);
+ throw new Error(body?.msg ?? `请求失败(${res.status})`);
+ }
+
+ const reader = res.body.pipeThrough(new TextDecoderStream()).getReader();
+ let buffer = "";
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ buffer += value;
+ // 一次读到的数据可能含多行,也可能只有半行
+ for (let end = buffer.indexOf("\n"); end >= 0; end = buffer.indexOf("\n")) {
+ const line = buffer.slice(0, end).trim();
+ buffer = buffer.slice(end + 1);
+ if (line) onEvent(JSON.parse(line) as SearchEvent);
+ }
+ }
+};
diff --git a/src/services/torrents.ts b/src/services/torrents.ts
new file mode 100644
index 0000000..2603ea6
--- /dev/null
+++ b/src/services/torrents.ts
@@ -0,0 +1,12 @@
+// torrent 相关 API 请求
+import BaseClientService from "@/lib/axios";
+import type { ApiResponse } from "@/types/api";
+import type { AddTorrentResult } from "@/types/torbox";
+
+export const addMagnet = async (magnet: string) => {
+ const { data } = await BaseClientService.post>(
+ "/torrents",
+ { magnet }
+ );
+ return data.data;
+};
diff --git a/src/stores/search-store.ts b/src/stores/search-store.ts
new file mode 100644
index 0000000..34ce4c5
--- /dev/null
+++ b/src/stores/search-store.ts
@@ -0,0 +1,149 @@
+// 搜索任务状态 store
+//
+// 搜索是一个耗时的后台任务(慢站点要等十几秒),弹窗关闭后仍要继续,
+// 所以请求和结果都放在 store 里,不跟任何组件的生命周期绑定。
+import { create } from "zustand";
+
+import { message } from "@/lib/message";
+import { streamSearch } from "@/services/search";
+import type { SearchEvent, SearchResult } from "@/types/search";
+
+export type SearchStatus =
+ | "idle"
+ | "searching"
+ | "done"
+ | "cancelled"
+ | "failed";
+
+export type SearchFailure = {
+ indexerId: string;
+ indexerName: string;
+ error: string;
+};
+
+/** 结果加入 TorBox 的进度;没有记录表示还没添加 */
+export type TorrentStatus = "adding" | "added";
+
+type SearchState = {
+ status: SearchStatus;
+ /** 最近一次提交的关键词 */
+ query: string;
+ /** Indexer 总数,收到 start 事件前为 0 */
+ total: number;
+ /** 已返回(成功或失败)的 Indexer 数 */
+ completed: number;
+ /** 按到达顺序累积,排序交给展示层 */
+ results: SearchResult[];
+ failures: SearchFailure[];
+ /** status 为 failed 时的原因 */
+ error: string | null;
+ /** 按结果 id 记录;不随新搜索清空,同一个种子在弹窗里不会被重复添加 */
+ torrentStatus: Record;
+ open: boolean;
+ /** 搜索在弹窗关闭期间结束,用户还没看过结果 */
+ unseen: boolean;
+ setOpen: (open: boolean) => void;
+ /** 记录结果加入 TorBox 的进度;不传 status 表示清除(添加失败后可重试) */
+ setTorrentStatus: (id: string, status?: TorrentStatus) => void;
+ start: (query: string) => Promise;
+ cancel: () => void;
+};
+
+const initialTask: Pick<
+ SearchState,
+ "status" | "query" | "total" | "completed" | "results" | "failures" | "error"
+> = {
+ status: "idle",
+ query: "",
+ total: 0,
+ completed: 0,
+ results: [],
+ failures: [],
+ error: null
+};
+
+/** 当前搜索的中止器;换成新搜索或取消后,旧搜索的回调据此判断自己是否已过期 */
+let controller: AbortController | null = null;
+
+export const useSearchStore = create()((set, get) => {
+ /** 结束一次搜索;弹窗已关闭时用 toast 与按钮高亮告知 */
+ const settle = (patch: Partial, notify: () => void) => {
+ const hidden = !get().open;
+ set({ ...patch, unseen: hidden });
+ if (hidden) notify();
+ };
+
+ const handleEvent = (event: SearchEvent) => {
+ if (event.type === "start") set({ total: event.total });
+ else if (event.type === "indexer")
+ set((state) => ({
+ completed: state.completed + 1,
+ results: event.results.length
+ ? state.results.concat(event.results)
+ : state.results,
+ failures: event.error
+ ? [
+ ...state.failures,
+ {
+ indexerId: event.indexerId,
+ indexerName: event.indexerName,
+ error: event.error
+ }
+ ]
+ : state.failures
+ }));
+ else
+ settle({ status: "done" }, () => {
+ const { query, results } = get();
+ message.success(`「${query}」搜索完成,共 ${results.length} 条结果`);
+ });
+ };
+
+ const fail = (error: string) =>
+ settle({ status: "failed", error }, () =>
+ message.error(`「${get().query}」搜索失败:${error}`)
+ );
+
+ return {
+ ...initialTask,
+ torrentStatus: {},
+ open: false,
+ unseen: false,
+
+ setOpen: (open) => set(open ? { open, unseen: false } : { open }),
+
+ setTorrentStatus: (id, status) =>
+ set((state) => {
+ const next = { ...state.torrentStatus };
+ if (status) next[id] = status;
+ else delete next[id];
+ return { torrentStatus: next };
+ }),
+
+ start: async (query) => {
+ controller?.abort();
+ const current = new AbortController();
+ controller = current;
+ set({ ...initialTask, status: "searching", query, unseen: false });
+
+ try {
+ await streamSearch(query, current.signal, (event) => {
+ if (controller === current) handleEvent(event);
+ });
+ // 流正常结束却没收到 done:连接被中途切断
+ if (controller === current && get().status === "searching")
+ fail("连接中断,结果可能不完整");
+ } catch (error) {
+ if (controller !== current) return;
+ fail(error instanceof Error ? error.message : "未知错误");
+ }
+ },
+
+ cancel: () => {
+ if (get().status !== "searching") return;
+ controller?.abort();
+ controller = null;
+ set({ status: "cancelled" });
+ }
+ };
+});
diff --git a/src/stores/search-view-store.ts b/src/stores/search-view-store.ts
new file mode 100644
index 0000000..40f3d77
--- /dev/null
+++ b/src/stores/search-view-store.ts
@@ -0,0 +1,32 @@
+// 搜索结果的筛选与排序状态 store
+import { create } from "zustand";
+
+import { SEARCH_SORT_OPTIONS } from "@/constants/search";
+import type { SearchSortKey, SortOrder } from "@/types/search";
+
+type SearchViewState = {
+ /** 在已有结果里按标题筛选,空格分隔多个词(全部命中才显示) */
+ keyword: string;
+ sortKey: SearchSortKey;
+ order: SortOrder;
+ setKeyword: (keyword: string) => void;
+ /** 切换排序方式,方向回到该方式的默认方向 */
+ setSortKey: (sortKey: SearchSortKey) => void;
+ setOrder: (order: SortOrder) => void;
+};
+
+/** 结果区的视图设置(工具栏与列表共享),不持久化;与搜索任务本身(search-store)分开 */
+export const useSearchViewStore = create()((set) => ({
+ keyword: "",
+ sortKey: "seeders",
+ order: "desc",
+ setKeyword: (keyword) => set({ keyword }),
+ setSortKey: (sortKey) =>
+ set({
+ sortKey,
+ order:
+ SEARCH_SORT_OPTIONS.find((option) => option.value === sortKey)
+ ?.defaultOrder ?? "desc"
+ }),
+ setOrder: (order) => set({ order })
+}));
diff --git a/src/types/search.ts b/src/types/search.ts
new file mode 100644
index 0000000..c0d89c4
--- /dev/null
+++ b/src/types/search.ts
@@ -0,0 +1,40 @@
+// 搜索类型:服务端与浏览器共用,只含可下发给浏览器的字段(不含 Jackett 的下载链接,其中带有 API Key)
+
+export type SearchResult = {
+ /** 去重键:优先 infohash,否则 Indexer + 站点条目标识 */
+ id: string;
+ title: string;
+ indexerId: string;
+ indexerName: string;
+ /** 字节数;站点未提供时为 null */
+ size: number | null;
+ /** 做种者 */
+ seeders: number | null;
+ /** 下载者;Jackett 只给「做种+下载」的总数,这里是总数减做种数 */
+ leechers: number | null;
+ /** 发布时间(ISO);站点未提供时为 null */
+ publishedAt: string | null;
+ /** 站点详情页 */
+ detailsUrl: string | null;
+ /** 站点未直接给出 magnet 时为 null(Jackett 需要再请求一次才能拿到) */
+ magnet: string | null;
+};
+
+export type SearchSortKey = "name" | "seeders" | "leechers" | "size" | "date";
+
+export type SortOrder = "asc" | "desc";
+
+/** NDJSON 流中的一行:start → 若干 indexer → done */
+export type SearchEvent =
+ | { type: "start"; total: number }
+ | {
+ type: "indexer";
+ indexerId: string;
+ indexerName: string;
+ durationMs: number;
+ /** 去掉了本次搜索中已出现过的重复结果 */
+ results: SearchResult[];
+ /** 有值表示该 Indexer 失败,results 为空 */
+ error?: string;
+ }
+ | { type: "done" };
diff --git a/src/types/torbox.ts b/src/types/torbox.ts
index e050441..ae448f1 100644
--- a/src/types/torbox.ts
+++ b/src/types/torbox.ts
@@ -36,6 +36,13 @@ export type LibraryItem = {
files: LibraryFile[];
};
+/** 添加 magnet 成功后的结果 */
+export type AddTorrentResult = {
+ /** TorBox 的 torrent_id,与资源库中 torrents 条目的 id 一致 */
+ id: number;
+ hash: string;
+};
+
export type LibraryResponse = {
items: LibraryItem[];
/** 拉取失败的来源及原因 */
diff --git a/src/utils/date.ts b/src/utils/date.ts
index 2295f67..5844cc0 100644
--- a/src/utils/date.ts
+++ b/src/utils/date.ts
@@ -41,3 +41,29 @@ export const formatDateTime = (iso: string) =>
dateStyle: "short",
timeStyle: "short"
});
+
+/** 本地化日期,如 2026/10/05 */
+export const formatDate = (iso: string) =>
+ new Date(iso).toLocaleDateString("zh-CN");
+
+/** 相对日期按自然日算:7 天内为「今天 / 昨天 / n 天前」,更早或晚于 now 显示 2026年10月05日 */
+const RELATIVE_DAYS = 7;
+
+export const formatRelativeDate = (iso: string, now: number) => {
+ const time = toTimestamp(iso);
+ if (time === null) return "";
+
+ const date = new Date(time);
+ const startOfDay = (value: Date) =>
+ new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime();
+ // 用本地自然日相减,不会因为夏令时之类的偏移算错天数
+ const days = Math.round((startOfDay(new Date(now)) - startOfDay(date)) / DAY);
+
+ if (days === 0) return "今天";
+ if (days === 1) return "昨天";
+ if (days > 1 && days < RELATIVE_DAYS) return `${days} 天前`;
+
+ const month = String(date.getMonth() + 1).padStart(2, "0");
+ const day = String(date.getDate()).padStart(2, "0");
+ return `${date.getFullYear()}年${month}月${day}日`;
+};
diff --git a/src/utils/magnet.ts b/src/utils/magnet.ts
new file mode 100644
index 0000000..2107a68
--- /dev/null
+++ b/src/utils/magnet.ts
@@ -0,0 +1,8 @@
+// 磁力链接校验(纯函数,前后端共用)
+
+/** btih 为 40 位十六进制或 32 位 base32 */
+const BTIH = /[?&]xt=urn:btih:(?:[0-9a-f]{40}|[a-z2-7]{32})(?:&|$)/i;
+
+/** 是否为包含合法 info hash 的 magnet 链接 */
+export const isMagnetLink = (value: string) =>
+ /^magnet:\?/i.test(value) && BTIH.test(value);