This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
# TorBox API Key,仅服务端使用,不会下发到浏览器
|
||||
# 不配置时以演示模式启动:资源库显示内置模拟数据(src/server/mock-library.ts)
|
||||
TORBOX_API_KEY=
|
||||
# Jackett 地址,如 http://192.168.1.10:9117
|
||||
JACKETT_URL=
|
||||
# Jackett API Key
|
||||
JACKETT_API_KEY=
|
||||
# 自定义访问密钥:网页登录、播放链接、播放列表都用它鉴权
|
||||
# 演示模式下可不配置,此时访问密钥为 mock;配置了 TORBOX_API_KEY 时必须配置,否则拒绝启动
|
||||
ACCESS_TOKEN=
|
||||
|
||||
@@ -76,6 +76,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
|
||||
- **例外三·分组标题吸顶**:资源库的分组标题行用原生 `sticky`(全站唯一),由浏览器合成,滚动时不跑 JS。虚拟列表把每个分组的可见行包进一个与整组等高的容器(未渲染部分用占位块撑出),标题以它为边界,到分组末尾时被下一组自然推出;`rangeExtractor` 保证当前分组的标题始终渲染。吸附时的底边线用 `stuck-divider`(scroll-state 容器查询);容器查询改不了容器自身,吸附时标题行两侧各外扩 6px 由 `useStuck`(IntersectionObserver,观察边界比吸附位置低 1px)驱动。
|
||||
- 单格 grid 里放 canvas 时,轨道必须是 `grid-cols-1 grid-rows-1`(`minmax(0, 1fr)`),否则画布的像素尺寸会反过来撑大布局。
|
||||
- shadcn 自带浮层(Dialog、Toast、Tooltip、DropdownMenu)与图片预览灯箱内部的定位不受此限。
|
||||
- **浮层层级**:Dialog、DropdownMenu、Tooltip 为 `z-50`,图片灯箱 `z-90`(`image-lightbox.tsx` 覆盖库默认的 9999),Toast 视口 `z-100`。通知必须盖过所有浮层,否则在弹窗内触发的 `message.*` 会被弹窗挡住;新增浮层组件不要超过 `z-90`。
|
||||
|
||||
### 长列表(虚拟滚动)
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"axios": "^1.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"cn": "^0.3.2",
|
||||
"fast-xml-parser": "^5.11.2",
|
||||
"lucide-react": "^1.47.0",
|
||||
"motion": "^14.0.0",
|
||||
"next": "16.3.5",
|
||||
|
||||
Generated
+58
@@ -35,6 +35,9 @@ importers:
|
||||
cn:
|
||||
specifier: ^0.3.2
|
||||
version: 0.3.2
|
||||
fast-xml-parser:
|
||||
specifier: ^5.11.2
|
||||
version: 5.11.2
|
||||
lucide-react:
|
||||
specifier: ^1.47.0
|
||||
version: 1.47.0(react@19.2.8)
|
||||
@@ -634,6 +637,9 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@nodable/entities@3.1.0':
|
||||
resolution: {integrity: sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1094,6 +1100,9 @@ packages:
|
||||
resolution: {integrity: sha512-VbqNsoz55SYGczauuup0MFUyXNQviSpFTj1RQtFzmQLk18qbVSpTFFGMT293rmDaQuKCT6InmbuEyUne4mTuxQ==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
anynum@1.0.1:
|
||||
resolution: {integrity: sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==}
|
||||
|
||||
argparse@2.0.1:
|
||||
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
|
||||
|
||||
@@ -1714,6 +1723,13 @@ packages:
|
||||
fast-uri@3.1.8:
|
||||
resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==}
|
||||
|
||||
fast-xml-builder@1.3.1:
|
||||
resolution: {integrity: sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==}
|
||||
|
||||
fast-xml-parser@5.11.2:
|
||||
resolution: {integrity: sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==}
|
||||
hasBin: true
|
||||
|
||||
fastq@1.20.3:
|
||||
resolution: {integrity: sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==}
|
||||
|
||||
@@ -2156,6 +2172,9 @@ packages:
|
||||
resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
is-unsafe@2.0.2:
|
||||
resolution: {integrity: sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==}
|
||||
|
||||
is-weakmap@2.0.2:
|
||||
resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2653,6 +2672,10 @@ packages:
|
||||
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
path-expression-matcher@1.6.2:
|
||||
resolution: {integrity: sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
path-key@3.1.1:
|
||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3090,6 +3113,9 @@ packages:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
strnum@2.4.2:
|
||||
resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
|
||||
|
||||
styled-jsx@5.1.6:
|
||||
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -3305,6 +3331,10 @@ packages:
|
||||
resolution: {integrity: sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
xml-naming@0.3.0:
|
||||
resolution: {integrity: sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==}
|
||||
engines: {node: '>=16.0.0'}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
@@ -3923,6 +3953,8 @@ snapshots:
|
||||
'@next/swc-win32-x64-msvc@16.3.5':
|
||||
optional: true
|
||||
|
||||
'@nodable/entities@3.1.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -4377,6 +4409,8 @@ snapshots:
|
||||
|
||||
ansi-styles@6.1.0: {}
|
||||
|
||||
anynum@1.0.1: {}
|
||||
|
||||
argparse@2.0.1: {}
|
||||
|
||||
aria-query@5.3.2: {}
|
||||
@@ -5187,6 +5221,20 @@ snapshots:
|
||||
|
||||
fast-uri@3.1.8: {}
|
||||
|
||||
fast-xml-builder@1.3.1:
|
||||
dependencies:
|
||||
path-expression-matcher: 1.6.2
|
||||
xml-naming: 0.3.0
|
||||
|
||||
fast-xml-parser@5.11.2:
|
||||
dependencies:
|
||||
'@nodable/entities': 3.1.0
|
||||
fast-xml-builder: 1.3.1
|
||||
is-unsafe: 2.0.2
|
||||
path-expression-matcher: 1.6.2
|
||||
strnum: 2.4.2
|
||||
xml-naming: 0.3.0
|
||||
|
||||
fastq@1.20.3:
|
||||
dependencies:
|
||||
reusify: 1.1.0
|
||||
@@ -5600,6 +5648,8 @@ snapshots:
|
||||
|
||||
is-unicode-supported@2.1.0: {}
|
||||
|
||||
is-unsafe@2.0.2: {}
|
||||
|
||||
is-weakmap@2.0.2: {}
|
||||
|
||||
is-weakref@1.1.1:
|
||||
@@ -6059,6 +6109,8 @@ snapshots:
|
||||
|
||||
path-exists@4.0.0: {}
|
||||
|
||||
path-expression-matcher@1.6.2: {}
|
||||
|
||||
path-key@3.1.1: {}
|
||||
|
||||
path-key@4.0.0: {}
|
||||
@@ -6606,6 +6658,10 @@ snapshots:
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strnum@2.4.2:
|
||||
dependencies:
|
||||
anynum: 1.0.1
|
||||
|
||||
styled-jsx@5.1.6(@babel/core@7.29.7)(react@19.2.8):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -6844,6 +6900,8 @@ snapshots:
|
||||
is-wsl: 3.1.1
|
||||
powershell-utils: 0.1.0
|
||||
|
||||
xml-naming@0.3.0: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
yet-another-react-lightbox@3.32.2(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8):
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// 顶栏「添加磁力」按钮:打开添加磁力链接弹窗
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { MagnetIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { AddMagnetDialog } from "./add-magnet-dialog";
|
||||
|
||||
export const AddMagnetButton = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setOpen(true)}>
|
||||
<MagnetIcon data-icon="inline-start" />
|
||||
{/* 手机宽度下只显示图标,文字留给读屏 */}
|
||||
<span className="sr-only sm:not-sr-only">添加磁力</span>
|
||||
</Button>
|
||||
<AddMagnetDialog open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
// 添加磁力链接弹窗:粘贴 magnet 后添加到 TorBox
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { CloudDownloadIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Field,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel
|
||||
} from "@/components/ui/field";
|
||||
import { InputGroup, InputGroupTextarea } from "@/components/ui/input-group";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { MAX_MAGNET_LENGTH } from "@/constants/torbox";
|
||||
import { useAddMagnet } from "@/hooks/use-torrents";
|
||||
import { isMagnetLink } from "@/utils/magnet";
|
||||
|
||||
const FORM_ID = "add-magnet-form";
|
||||
|
||||
/** 弹窗每次打开都会重新挂载,输入内容随之清空 */
|
||||
const AddMagnetForm = ({ onAdded }: { onAdded: () => void }) => {
|
||||
const [draft, setDraft] = useState("");
|
||||
const { mutate, isPending } = useAddMagnet();
|
||||
const magnet = draft.trim();
|
||||
const invalid = magnet !== "" && !isMagnetLink(magnet);
|
||||
|
||||
return (
|
||||
<>
|
||||
<form
|
||||
id={FORM_ID}
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!magnet || invalid) return;
|
||||
// 失败提示由 axios 拦截器统一处理,弹窗保持打开以便修改后重试
|
||||
mutate(magnet, { onSuccess: onAdded });
|
||||
}}
|
||||
>
|
||||
<FieldGroup>
|
||||
<Field data-invalid={invalid || undefined}>
|
||||
<FieldLabel htmlFor="add-magnet-input">磁力链接</FieldLabel>
|
||||
<InputGroup>
|
||||
<InputGroupTextarea
|
||||
id="add-magnet-input"
|
||||
name="magnet"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
maxLength={MAX_MAGNET_LENGTH}
|
||||
rows={4}
|
||||
placeholder="magnet:?xt=urn:btih:..."
|
||||
aria-invalid={invalid || undefined}
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
className="font-mono"
|
||||
/>
|
||||
</InputGroup>
|
||||
{invalid ? <FieldError>请输入有效的磁力链接</FieldError> : null}
|
||||
</Field>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="submit"
|
||||
form={FORM_ID}
|
||||
disabled={isPending || !magnet || invalid}
|
||||
>
|
||||
{isPending ? (
|
||||
<Spinner data-icon="inline-start" />
|
||||
) : (
|
||||
<CloudDownloadIcon data-icon="inline-start" />
|
||||
)}
|
||||
添加到 TorBox
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type AddMagnetDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const AddMagnetDialog = ({
|
||||
open,
|
||||
onOpenChange
|
||||
}: AddMagnetDialogProps) => (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加磁力链接</DialogTitle>
|
||||
</DialogHeader>
|
||||
<AddMagnetForm onAdded={() => onOpenChange(false)} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
@@ -3,16 +3,20 @@ import { BrandMark } from "@/components/brand-mark";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { AddMagnetButton } from "./add-magnet-button";
|
||||
import { LogoutButton } from "./logout-button";
|
||||
import { PlaylistMenu } from "./playlist-menu";
|
||||
import { SearchButton } from "./search-button";
|
||||
|
||||
type AppHeaderProps = {
|
||||
token: string;
|
||||
/** 演示模式:未配置 TORBOX_API_KEY,资源库为模拟数据 */
|
||||
mock: boolean;
|
||||
/** 已配置 Jackett(或演示模式);否则不显示搜索入口 */
|
||||
searchEnabled: boolean;
|
||||
};
|
||||
|
||||
export const AppHeader = ({ token, mock }: AppHeaderProps) => (
|
||||
export const AppHeader = ({ token, mock, searchEnabled }: AppHeaderProps) => (
|
||||
<header className="shrink-0 border-b">
|
||||
<div className="mx-auto flex h-14 w-full max-w-7xl items-center gap-3 px-4 md:px-6">
|
||||
<BrandMark />
|
||||
@@ -27,6 +31,8 @@ export const AppHeader = ({ token, mock }: AppHeaderProps) => (
|
||||
</Badge>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<AddMagnetButton />
|
||||
{searchEnabled && <SearchButton />}
|
||||
<PlaylistMenu token={token} />
|
||||
<Separator orientation="vertical" className="mx-1 my-4" />
|
||||
<LogoutButton />
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(finished && "text-primary", failed && "text-destructive")}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{searching ? (
|
||||
<Spinner data-icon="inline-start" />
|
||||
) : finished ? (
|
||||
<CheckIcon data-icon="inline-start" />
|
||||
) : failed ? (
|
||||
<TriangleAlertIcon data-icon="inline-start" />
|
||||
) : (
|
||||
<SearchIcon data-icon="inline-start" />
|
||||
)}
|
||||
{/* 手机宽度下只显示图标,文字留给读屏 */}
|
||||
<span className="sr-only sm:not-sr-only">
|
||||
{searching
|
||||
? "搜索中"
|
||||
: finished
|
||||
? "搜索完成"
|
||||
: failed
|
||||
? "搜索失败"
|
||||
: "搜索"}
|
||||
</span>
|
||||
{searching && total > 0 && (
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{completed}/{total}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<SearchDialog />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!keyword) return;
|
||||
// 新结果与旧的筛选词无关,清掉避免一上来就是空列表
|
||||
useSearchViewStore.getState().setKeyword("");
|
||||
void start(keyword);
|
||||
}}
|
||||
>
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.target.value)}
|
||||
maxLength={MAX_QUERY_LENGTH}
|
||||
placeholder="输入关键词,如片名"
|
||||
aria-label="搜索关键词"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<InputGroupAddon align="inline-end">
|
||||
{status === "searching" ? (
|
||||
<InputGroupButton variant="outline" onClick={cancel}>
|
||||
取消
|
||||
</InputGroupButton>
|
||||
) : (
|
||||
<InputGroupButton
|
||||
type="submit"
|
||||
variant="default"
|
||||
disabled={!keyword}
|
||||
>
|
||||
搜索
|
||||
</InputGroupButton>
|
||||
)}
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-3">
|
||||
<Progress
|
||||
// 收到 start 事件前不知道总数,显示为不确定进度
|
||||
value={total ? (completed / total) * 100 : null}
|
||||
aria-label="搜索进度"
|
||||
>
|
||||
<ProgressLabel>{progressLabel(status, resultCount)}</ProgressLabel>
|
||||
<ProgressValue>
|
||||
{() => (total ? `${completed} / ${total} 个站点` : "")}
|
||||
</ProgressValue>
|
||||
</Progress>
|
||||
|
||||
{status === "failed" && error && (
|
||||
<Alert variant="destructive">
|
||||
<TriangleAlertIcon />
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{failures.length > 0 && (
|
||||
<Collapsible>
|
||||
<CollapsibleTrigger className="group flex items-center gap-1 text-xs text-muted-foreground outline-none hover:text-foreground focus-visible:text-foreground">
|
||||
{failures.length} 个站点没有返回结果
|
||||
<ChevronDownIcon className="size-3.5 transition-transform group-aria-expanded:rotate-180" />
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<ul className="mt-2 flex max-h-24 flex-col gap-1 overflow-y-auto font-mono text-xs text-muted-foreground">
|
||||
{failures.map((failure) => (
|
||||
<li
|
||||
key={failure.indexerId}
|
||||
className="truncate"
|
||||
title={failure.error}
|
||||
>
|
||||
{failure.indexerName} — {failure.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-2">
|
||||
<SearchResultToolbar total={results.length} shown={items.length} />
|
||||
{items.length > 0 ? (
|
||||
<SearchResultList
|
||||
items={items}
|
||||
terms={terms}
|
||||
resetScrollKey={`${sortKey}:${order}:${terms.join(" ")}`}
|
||||
className={RESULT_HEIGHT}
|
||||
/>
|
||||
) : (
|
||||
<Empty className={RESULT_HEIGHT}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<ListFilterIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>没有匹配的结果</EmptyTitle>
|
||||
<EmptyDescription>
|
||||
共 {results.length} 条结果中没有同时包含这些词的
|
||||
</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button variant="outline" size="sm" onClick={() => setKeyword("")}>
|
||||
清除筛选
|
||||
</Button>
|
||||
</Empty>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (status === "searching")
|
||||
return (
|
||||
<div
|
||||
className={cn("flex flex-col gap-2", RESULT_HEIGHT)}
|
||||
aria-busy="true"
|
||||
aria-label="正在等待站点返回"
|
||||
>
|
||||
{Array.from({ length: 10 }, (_, index) => (
|
||||
<Skeleton key={index} className="h-14 shrink-0" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const idle = status === "idle";
|
||||
return (
|
||||
<Empty className={RESULT_HEIGHT}>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia variant="icon">
|
||||
<SearchIcon />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{idle ? "输入关键词开始搜索" : "没有找到结果"}</EmptyTitle>
|
||||
<EmptyDescription>{idle ? "" : "换个关键词试试"}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
</Empty>
|
||||
);
|
||||
};
|
||||
|
||||
export const SearchDialog = () => {
|
||||
const open = useSearchStore((state) => state.open);
|
||||
const setOpen = useSearchStore((state) => state.setOpen);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
{/* grid-cols-1:轨道可被压缩,内容再宽也不会把弹窗撑出屏幕 */}
|
||||
<DialogContent className="grid-cols-1 sm:max-w-5xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>搜索资源</DialogTitle>
|
||||
</DialogHeader>
|
||||
<SearchForm />
|
||||
<SearchProgress />
|
||||
<SearchResults />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -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) => (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={label}
|
||||
className={className}
|
||||
disabled={pending || disabled}
|
||||
onClick={onClick}
|
||||
{...(href
|
||||
? {
|
||||
nativeButton: false,
|
||||
render: (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer" />
|
||||
)
|
||||
}
|
||||
: {})}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{pending ? <Spinner /> : <Icon />}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
/** 每行都能跳转到站点;有 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 && (
|
||||
<>
|
||||
<RowAction
|
||||
label="复制 magnet 链接"
|
||||
icon={CopyIcon}
|
||||
onClick={() => copy(magnet, "已复制 magnet 链接")}
|
||||
/>
|
||||
<RowAction
|
||||
label={
|
||||
status === "added"
|
||||
? "已添加到 TorBox"
|
||||
: status === "adding"
|
||||
? "正在添加到 TorBox"
|
||||
: "添加到 TorBox"
|
||||
}
|
||||
icon={status === "added" ? CheckIcon : CloudDownloadIcon}
|
||||
pending={status === "adding"}
|
||||
disabled={status === "added"}
|
||||
className={cn(status === "added" && "text-primary")}
|
||||
onClick={() => addToTorbox(magnet)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{detailsUrl && (
|
||||
<RowAction
|
||||
label="打开站点页面"
|
||||
icon={ExternalLinkIcon}
|
||||
href={detailsUrl}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
/** 做种 / 下载数:只显示图标与数字,名称放在 title 与读屏文字里 */
|
||||
const Stat = ({
|
||||
label,
|
||||
icon: Icon,
|
||||
value
|
||||
}: {
|
||||
label: string;
|
||||
icon: LucideIcon;
|
||||
value: number;
|
||||
}) => (
|
||||
<span className="inline-flex items-center gap-1" title={label}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">{label}</span>
|
||||
{formatCount(value)}
|
||||
</span>
|
||||
);
|
||||
|
||||
const ResultRow = memo(function ResultRow({
|
||||
result,
|
||||
terms,
|
||||
now
|
||||
}: {
|
||||
result: SearchResult;
|
||||
terms: readonly string[];
|
||||
now: number;
|
||||
}) {
|
||||
const { publishedAt, size, seeders, leechers } = result;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 border-b px-1 py-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
{/* 站点没给发布时间时留空,来源仍靠右 */}
|
||||
<span className="font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{publishedAt ? formatRelativeDate(publishedAt, now) : null}
|
||||
</span>
|
||||
<Badge variant="outline" className="min-w-0">
|
||||
<span className="truncate">{result.indexerName}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{/* 名称不省略;没有空格的超长名称也要能在容器内断行 */}
|
||||
<span className="text-sm font-medium wrap-anywhere">
|
||||
<HighlightText text={result.title} terms={terms} />
|
||||
</span>
|
||||
<div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 font-mono text-xs text-muted-foreground tabular-nums">
|
||||
{size != null && <span>{formatBytes(size)}</span>}
|
||||
{seeders != null && (
|
||||
<Stat label="做种" icon={ArrowUpIcon} value={seeders} />
|
||||
)}
|
||||
{leechers != null && (
|
||||
<Stat label="下载" icon={ArrowDownIcon} value={leechers} />
|
||||
)}
|
||||
</div>
|
||||
{/* 窄屏换到下一行,靠右 */}
|
||||
<div className="flex items-center gap-1 self-end sm:self-auto">
|
||||
<ResultActions result={result} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
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 (
|
||||
<VirtualList
|
||||
items={items}
|
||||
getItemKey={(result) => result.id}
|
||||
estimateSize={ESTIMATED_ROW_HEIGHT}
|
||||
renderItem={(result) => (
|
||||
<ResultRow result={result} terms={terms} now={now} />
|
||||
)}
|
||||
resetScrollKey={resetScrollKey}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
className="shrink-0"
|
||||
aria-label={`排序:${label},${orderLabel}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ArrowDownUpIcon data-icon="inline-start" />
|
||||
{label}
|
||||
{order === "asc" ? (
|
||||
<ArrowUpIcon data-icon="inline-end" />
|
||||
) : (
|
||||
<ArrowDownIcon data-icon="inline-end" />
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-40">
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>排序方式</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={sortKey}
|
||||
onValueChange={(value: SearchSortKey) => setSortKey(value)}
|
||||
>
|
||||
{SEARCH_SORT_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel>顺序</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
value={order}
|
||||
onValueChange={(value: SortOrder) => setOrder(value)}
|
||||
>
|
||||
{ORDER_OPTIONS.map((option) => (
|
||||
<DropdownMenuRadioItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
closeOnClick
|
||||
>
|
||||
{option.label}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="flex items-center gap-2">
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<ListFilterIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="在结果中筛选,空格分隔多个词"
|
||||
aria-label="在结果中筛选"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{keyword && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupText className="font-mono text-xs tabular-nums">
|
||||
{shown} / {total}
|
||||
</InputGroupText>
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
aria-label="清除筛选"
|
||||
onClick={() => setKeyword("")}
|
||||
>
|
||||
<XIcon />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
<SortMenu />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
<div className="flex h-dvh flex-col">
|
||||
<AppHeader token={token} mock={isMockMode()} />
|
||||
<AppHeader
|
||||
token={token}
|
||||
mock={isMockMode()}
|
||||
searchEnabled={isSearchAvailable()}
|
||||
/>
|
||||
<main className="flex min-h-0 flex-1 flex-col">{children}</main>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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<SearchEvent>(req, async (send, signal) => {
|
||||
send({ type: "start", total: indexers.length });
|
||||
await runSearch(indexers, query, signal, send);
|
||||
send({ type: "done" });
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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:命中词跨行断开时,每一段都保留完整的圆角与内边距
|
||||
<mark
|
||||
key={index}
|
||||
className="rounded-sm bg-primary px-0.5 text-primary-foreground [box-decoration-break:clone]"
|
||||
>
|
||||
{part}
|
||||
</mark>
|
||||
) : (
|
||||
<Fragment key={index}>{part}</Fragment>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -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)",
|
||||
|
||||
@@ -29,7 +29,9 @@ function ToastViewport({ className, ...props }: ToastPrimitive.Viewport.Props) {
|
||||
<ToastPrimitive.Viewport
|
||||
data-slot="toast-viewport"
|
||||
className={cn(
|
||||
"pointer-events-none fixed top-4 left-1/2 z-50 w-full max-w-sm -translate-x-1/2 outline-none",
|
||||
// 层级高于 Dialog、DropdownMenu、Tooltip 等浮层(z-50):它们在打开时才挂到 body 末尾,
|
||||
// 层级相同会盖住通知
|
||||
"pointer-events-none fixed top-4 left-1/2 z-100 w-full max-w-sm -translate-x-1/2 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
// 通用虚拟列表:行高不固定,渲染后自动测量;只渲染可见行(及上下各 overscan 行)
|
||||
//
|
||||
// 滚动容器就是组件自身,需要通过 className 给它确定的高度(或放进 flex-1 min-h-0 的容器)。
|
||||
// 不使用定位:上下各放一个占位块撑出未渲染部分的高度,可见行保持在正常文档流里。
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, type ReactNode } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type VirtualListProps<T> = {
|
||||
items: readonly T[];
|
||||
/** 稳定的行标识;重新排序后已测量的行高会跟着行走 */
|
||||
getItemKey: (item: T) => string | number;
|
||||
/** 未测量前的估计行高(px),越接近实际值滚动条越稳 */
|
||||
estimateSize: number;
|
||||
renderItem: (item: T) => ReactNode;
|
||||
overscan?: number;
|
||||
/** 值变化时滚回顶部,如筛选、排序条件改变;流式追加数据不应改变它 */
|
||||
resetScrollKey?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const VirtualList = <T,>({
|
||||
items,
|
||||
getItemKey,
|
||||
estimateSize,
|
||||
renderItem,
|
||||
overscan = 8,
|
||||
resetScrollKey,
|
||||
className
|
||||
}: VirtualListProps<T>) => {
|
||||
const scrollRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={scrollRef} className={cn("overflow-y-auto", className)}>
|
||||
<div style={{ height: top }} />
|
||||
{rows.map((row) => (
|
||||
// measureElement 读取 data-index 并监听尺寸变化
|
||||
<div
|
||||
key={row.key}
|
||||
data-index={row.index}
|
||||
ref={virtualizer.measureElement}
|
||||
>
|
||||
{renderItem(items[row.index])}
|
||||
</div>
|
||||
))}
|
||||
<div style={{ height: bottom }} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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" }
|
||||
];
|
||||
@@ -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",
|
||||
|
||||
@@ -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<SearchResult> => {
|
||||
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 };
|
||||
};
|
||||
@@ -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?.()
|
||||
});
|
||||
};
|
||||
+25
-3
@@ -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));
|
||||
|
||||
@@ -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 + <error>,或 4xx 带 <error>) */
|
||||
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<string, any>; // 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|&#x[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<Indexer[]> } | 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<string, string>(
|
||||
(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<SearchResult[]> => {
|
||||
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) ?? []);
|
||||
};
|
||||
@@ -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<SearchResult[]> =>
|
||||
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 }
|
||||
);
|
||||
});
|
||||
@@ -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 = <T>(
|
||||
req: Request,
|
||||
run: (send: (event: T) => void, signal: AbortSignal) => Promise<void>
|
||||
) => {
|
||||
const abort = new AbortController();
|
||||
req.signal.addEventListener("abort", () => abort.abort(), { once: true });
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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"
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<string>();
|
||||
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
|
||||
},
|
||||
"搜索完成"
|
||||
);
|
||||
};
|
||||
+31
-1
@@ -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<AddTorrentResult> => {
|
||||
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 后可播放真实资源";
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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<ApiResponse<AddTorrentResult>>(
|
||||
"/torrents",
|
||||
{ magnet }
|
||||
);
|
||||
return data.data;
|
||||
};
|
||||
@@ -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<string, TorrentStatus>;
|
||||
open: boolean;
|
||||
/** 搜索在弹窗关闭期间结束,用户还没看过结果 */
|
||||
unseen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
/** 记录结果加入 TorBox 的进度;不传 status 表示清除(添加失败后可重试) */
|
||||
setTorrentStatus: (id: string, status?: TorrentStatus) => void;
|
||||
start: (query: string) => Promise<void>;
|
||||
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<SearchState>()((set, get) => {
|
||||
/** 结束一次搜索;弹窗已关闭时用 toast 与按钮高亮告知 */
|
||||
const settle = (patch: Partial<SearchState>, 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" });
|
||||
}
|
||||
};
|
||||
});
|
||||
@@ -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<SearchViewState>()((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 })
|
||||
}));
|
||||
@@ -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" };
|
||||
@@ -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[];
|
||||
/** 拉取失败的来源及原因 */
|
||||
|
||||
@@ -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}日`;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user