feat: 新增批量管理及回收站功能
torbox-admin / deploy (push) Successful in 2m39s

This commit is contained in:
2026-10-09 06:46:39 -07:00
parent 97b8a6e199
commit 86c96b1a32
32 changed files with 1869 additions and 247 deletions
+1
View File
@@ -109,6 +109,7 @@ div.flex.h-dvh.flex-col ← (app)/layout.tsx
| 图标按钮说明 | `Tooltip` |
| 视频播放 | `VideoPlayer`(Video.js v10 + `components/videojs` 皮肤,`I18nProvider` 固定中文);资源库在 `Dialog` 中播放(类似视频网站的播放页):手机全屏,`sm` 起铺满顶栏以下、顶栏保持可见,内容限宽 1920px 居中;非模态(`modal={false}`),看视频时顶栏照常可用,被盖住的 `<main>` 设为 `inert`;不渲染变暗模糊的遮罩(`showOverlay={false}`),点击弹窗外不关闭(`disablePointerDismissal`),`next/dynamic` 按需加载;MP4、M4V、WebM 直接播放原文件,其他格式经 TorBox 转码为 HLS(`HlsJsVideo`,hls.js 只在此时加载),信息行注明「TorBox 转码」;`onClose` 在画面左上角放关闭按钮(皮肤的 `topStart` 插槽,样式与显隐同右上角按钮组);标题右侧提供下载与「PotPlayer 播放」(手机宽度 `< md` 隐藏 PotPlayer);播放错误说明替换为中文并提示换用 PotPlayer |
| 图片预览 | `ImageLightbox`(yet-another-react-lightbox + Zoom):传入一组图片与当前序号;资源库以一个资源分组内的图片为一组,组内首尾循环,不跨组;列表只渲染一个灯箱 |
| 选择模式 | 工具栏「选择」进入:卡片点击改为勾选(Shift 连选),资源标题行有独立勾选框(永久删除)与「快捷选择」菜单;底部 `LibrarySelectionBar` 是列表下方的 `shrink-0` flex 行,提供标记删除 / 恢复 / 永久删除。标记删除只在本地隐藏文件并清图片缓存,「显示范围 · 回收站」中恢复;疑似广告扩展名见 `AD_FILE_EXT` |
| 提示消息 | `src/lib/message.ts`(不直接调用 `toast`) |
| 错误提示 | `Alert variant="destructive"` |
| 空状态 | `Empty` |
@@ -0,0 +1,7 @@
CREATE TABLE `torbox_hidden_files` (
`source` text NOT NULL,
`item_id` integer NOT NULL,
`file_id` integer NOT NULL,
`hidden_at` integer NOT NULL,
CONSTRAINT `torbox_hidden_files_pk` PRIMARY KEY(`source`, `item_id`, `file_id`)
);
@@ -0,0 +1,491 @@
{
"version": "7",
"dialect": "sqlite",
"id": "55ec07bd-20b2-4cfd-8a47-12102ad8d621",
"prevIds": [
"c777dfae-9036-46d7-9cb0-54cea903c801"
],
"ddl": [
{
"name": "image_cache",
"entityType": "tables"
},
{
"name": "torbox_deleted_items",
"entityType": "tables"
},
{
"name": "torbox_download_urls",
"entityType": "tables"
},
{
"name": "torbox_hidden_files",
"entityType": "tables"
},
{
"name": "torbox_rate_hits",
"entityType": "tables"
},
{
"name": "torbox_rate_pauses",
"entityType": "tables"
},
{
"name": "torbox_request_log",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'local'",
"generated": null,
"name": "location",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "size",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "content_type",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_access_at",
"entityType": "columns",
"table": "image_cache"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "source",
"entityType": "columns",
"table": "torbox_deleted_items"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_deleted_items"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "deleted_at",
"entityType": "columns",
"table": "torbox_deleted_items"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "key",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "url",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "torbox_download_urls"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "source",
"entityType": "columns",
"table": "torbox_hidden_files"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "item_id",
"entityType": "columns",
"table": "torbox_hidden_files"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "file_id",
"entityType": "columns",
"table": "torbox_hidden_files"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "hidden_at",
"entityType": "columns",
"table": "torbox_hidden_files"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "at",
"entityType": "columns",
"table": "torbox_rate_hits"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_rate_pauses"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "until",
"entityType": "columns",
"table": "torbox_rate_pauses"
},
{
"type": "integer",
"notNull": false,
"autoincrement": true,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "at",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "endpoint",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "cdn_host",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sent",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "status",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "duration_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "queued_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "limited_by",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "rule",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "retry_after_ms",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "usage",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "detail",
"entityType": "columns",
"table": "torbox_request_log"
},
{
"columns": [
"source",
"id"
],
"nameExplicit": false,
"name": "torbox_deleted_items_pk",
"entityType": "pks",
"table": "torbox_deleted_items"
},
{
"columns": [
"source",
"item_id",
"file_id"
],
"nameExplicit": false,
"name": "torbox_hidden_files_pk",
"entityType": "pks",
"table": "torbox_hidden_files"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "image_cache_pk",
"table": "image_cache",
"entityType": "pks"
},
{
"columns": [
"key"
],
"nameExplicit": false,
"name": "torbox_download_urls_pk",
"table": "torbox_download_urls",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "torbox_rate_hits_pk",
"table": "torbox_rate_hits",
"entityType": "pks"
},
{
"columns": [
"rule"
],
"nameExplicit": false,
"name": "torbox_rate_pauses_pk",
"table": "torbox_rate_pauses",
"entityType": "pks"
},
{
"columns": [
"id"
],
"nameExplicit": false,
"name": "torbox_request_log_pk",
"table": "torbox_request_log",
"entityType": "pks"
},
{
"columns": [
{
"value": "last_access_at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "image_cache_last_access_idx",
"entityType": "indexes",
"table": "image_cache"
},
{
"columns": [
{
"value": "rule",
"isExpression": false
},
{
"value": "at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "torbox_rate_hits_rule_at_idx",
"entityType": "indexes",
"table": "torbox_rate_hits"
},
{
"columns": [
{
"value": "at",
"isExpression": false
}
],
"isUnique": false,
"where": null,
"origin": "manual",
"name": "torbox_request_log_at_idx",
"entityType": "indexes",
"table": "torbox_request_log"
}
],
"renames": []
}
+137 -63
View File
@@ -14,12 +14,15 @@ import {
ImageOffIcon,
MonitorPlayIcon,
PlayIcon,
Trash2Icon,
Undo2Icon,
type LucideIcon
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardFooter } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
Tooltip,
TooltipContent,
@@ -33,9 +36,15 @@ import type { LibraryFileView } from "@/hooks/use-library";
import { useOrigin } from "@/hooks/use-origin";
import { useSpotlight } from "@/hooks/use-spotlight";
import { cn } from "@/lib/utils";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import type {
DownloadStatus,
LibraryFileRef,
LibraryItemRef
} from "@/types/torbox";
import { formatBytes } from "@/utils/format";
import type { DownloadStatus } from "@/types/torbox";
import { buildStreamUrl, toPotPlayerUrl, type MediaKind } from "@/utils/media";
import { getFileRefKey } from "@/utils/selection";
import { ImageCover } from "./image-cover";
import { CARD_FOCUS_PROPS } from "../library/library-dom";
@@ -189,6 +198,8 @@ const CardIconAction = ({
type FileCardProps = {
file: LibraryFileView;
/** 所属资源,选择、恢复以它加文件 id 定位 */
itemRef: LibraryItemRef;
token: string;
/** 所属资源的下载状态,非 ready 时不可播放、预览、下载或复制直链 */
status: DownloadStatus;
@@ -196,18 +207,32 @@ type FileCardProps = {
onOpen: () => void;
/** 这是最近播放的视频,封面高亮 */
played?: boolean;
/** 选择模式下点击卡片:勾选 / 取消,shift 为连选 */
onSelect: (ref: LibraryFileRef, shift: boolean) => void;
/** 回收站里恢复这个文件 */
onRestore: (ref: LibraryFileRef) => void;
};
export const FileCard = ({
file,
itemRef,
token,
status,
onOpen,
played = false
played = false,
onSelect,
onRestore
}: FileCardProps) => {
const origin = useOrigin();
const copy = useCopy();
const [imageFailed, setImageFailed] = useState(false);
const fileRef: LibraryFileRef = { ...itemRef, fileId: file.id };
const selecting = useLibrarySelectionStore((state) => state.active);
const selected = useLibrarySelectionStore(
(state) => getFileRefKey(fileRef) in state.files
);
/** 已标记删除:封面不请求图片,只能恢复 */
const trashed = file.hidden;
if (!origin) return null;
@@ -221,7 +246,9 @@ export const FileCard = ({
const primary = PRIMARY_ACTION[file.kind];
let cover: React.ReactNode;
if (!available) {
if (trashed) {
cover = <PlaceholderCover ext={file.ext} icon={Trash2Icon} />;
} else if (!available) {
cover = (
<PlaceholderCover
ext={file.ext}
@@ -249,68 +276,115 @@ export const FileCard = ({
}
return (
// 卡片作为容器查询的容器,底栏按卡片自身宽度调整
<Card size="sm" className="@container/card flex-1 gap-0 py-0">
{cover}
<CardContent className="flex flex-1 flex-col gap-1 py-3">
<p className="line-clamp-2 leading-snug break-all" title={file.name}>
{file.displayName}
</p>
<p className="font-mono text-xs text-muted-foreground text-end tabular-nums">
{formatBytes(file.size)}
</p>
</CardContent>
<CardFooter className="gap-2">
{primary && (
<Button
size="sm"
variant={primary.variant}
className="flex-1 aria-disabled:opacity-50"
disabled={!available}
focusableWhenDisabled
title={
available
? undefined
: status === "downloading"
? "资源尚未下载完成"
: "文件已不在 TorBox 服务器上"
}
onClick={onOpen}
{...CARD_FOCUS_PROPS}
>
<primary.icon data-icon="inline-start" />
{/* 卡片窄于 186px(手机两列)时只显示图标,给三个图标按钮留位置 */}
<span className="@max-[186px]/card:sr-only">{primary.label}</span>
</Button>
// 单格 grid 叠层:选择模式的勾选框叠在卡片左上角
<div className="grid flex-1 grid-cols-1 *:col-start-1 *:row-start-1">
{/* 卡片作为容器查询的容器,底栏按卡片自身宽度调整;
选择模式下在捕获阶段接管点击,卡片里的播放、下载等操作不会被触发 */}
<Card
size="sm"
className={cn(
"@container/card gap-0 py-0",
selecting && "select-none",
selected && "ring-2 ring-primary"
)}
{/* 次要操作只显示图标;没有主操作时整组靠右 */}
<div className="ml-auto flex items-center gap-1">
{file.kind === "video" && (
<CardIconAction
label="PotPlayer 播放"
icon={MonitorPlayIcon}
href={toPotPlayerUrl(streamUrl)}
disabled={!available}
// 手机上没有 PotPlayer,与播放弹窗一致不显示
className="hidden md:inline-flex"
/>
onClickCapture={
selecting
? (event) => {
event.preventDefault();
event.stopPropagation();
onSelect(fileRef, event.shiftKey);
}
: undefined
}
>
{cover}
<CardContent className="flex flex-1 flex-col gap-1 py-3">
<p className="line-clamp-2 leading-snug break-all" title={file.name}>
{file.displayName}
</p>
<p className="font-mono text-xs text-muted-foreground text-end tabular-nums">
{formatBytes(file.size)}
</p>
</CardContent>
<CardFooter className="gap-2">
{trashed ? (
<Button
size="sm"
variant="secondary"
className="ml-auto"
onClick={() => onRestore(fileRef)}
{...CARD_FOCUS_PROPS}
>
<Undo2Icon data-icon="inline-start" />
恢复
</Button>
) : (
<>
{primary && (
<Button
size="sm"
variant={primary.variant}
className="flex-1 aria-disabled:opacity-50"
disabled={!available}
focusableWhenDisabled
title={
available
? undefined
: status === "downloading"
? "资源尚未下载完成"
: "文件已不在 TorBox 服务器上"
}
onClick={onOpen}
{...CARD_FOCUS_PROPS}
>
<primary.icon data-icon="inline-start" />
{/* 卡片窄于 186px(手机两列)时只显示图标,给三个图标按钮留位置 */}
<span className="@max-[186px]/card:sr-only">
{primary.label}
</span>
</Button>
)}
{/* 次要操作只显示图标;没有主操作时整组靠右 */}
<div className="ml-auto flex items-center gap-1">
{file.kind === "video" && (
<CardIconAction
label="PotPlayer 播放"
icon={MonitorPlayIcon}
href={toPotPlayerUrl(streamUrl)}
disabled={!available}
// 手机上没有 PotPlayer,与播放弹窗一致不显示
className="hidden md:inline-flex"
/>
)}
<CardIconAction
label="下载"
icon={DownloadIcon}
href={streamUrl}
download={file.displayName}
disabled={!available}
focusTarget={!primary}
/>
<CardIconAction
label="复制直链"
icon={CopyIcon}
disabled={!available}
onClick={() => copy(streamUrl, "已复制直链")}
/>
</div>
</>
)}
<CardIconAction
label="下载"
icon={DownloadIcon}
href={streamUrl}
download={file.displayName}
disabled={!available}
focusTarget={!primary}
</CardFooter>
</Card>
{selecting && (
<span className="pointer-events-none z-10 self-start justify-self-start p-2">
<Checkbox
checked={selected}
tabIndex={-1}
aria-hidden="true"
className="size-5 bg-background/80 backdrop-blur-sm"
/>
<CardIconAction
label="复制直链"
icon={CopyIcon}
disabled={!available}
onClick={() => copy(streamUrl, "已复制直链")}
/>
</div>
</CardFooter>
</Card>
</span>
)}
</div>
);
};
@@ -28,7 +28,7 @@ import { LogoutButton } from "./logout-button";
/** 只在菜单打开时挂载,来源计数不在后台重复计算 */
const MenuFilters = () => {
const { sourceCounts } = useFilteredLibrary();
const { sourceCounts, hiddenCount } = useFilteredLibrary();
const filtered = useLibraryFilterStore(selectFacetFiltered);
const resetFacets = useLibraryFilterStore((state) => state.resetFacets);
@@ -52,7 +52,11 @@ const MenuFilters = () => {
</Button>
</div>
<FieldGroup className="gap-4">
<LibraryFilters sourceCounts={sourceCounts} layout="stacked" />
<LibraryFilters
sourceCounts={sourceCounts}
hiddenCount={hiddenCount}
layout="stacked"
/>
</FieldGroup>
</section>
);
@@ -1,4 +1,4 @@
// 删除资源确认弹窗:确认后从 TorBox 永久删除,并清掉图片缓存
// 永久删除资源确认弹窗:确认后从 TorBox 永久删除(可一次多个),并清掉图片缓存
"use client";
import { useState } from "react";
@@ -16,23 +16,26 @@ import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import { useCloseOnBack } from "@/hooks/use-close-on-back";
import {
useDeleteLibraryItem,
useDeleteLibraryItems,
type LibraryItemView
} from "@/hooks/use-library";
/** 弹窗里逐个列出资源名的上限,更多的只给数量 */
const MAX_LISTED = 5;
type DeleteItemDialogProps = {
/** 待删除的资源;为 null 时弹窗关闭 */
item: LibraryItemView | null;
/** 待删除的资源;为空时弹窗关闭 */
items: LibraryItemView[];
onClose: () => void;
};
export const DeleteItemDialog = ({ item, onClose }: DeleteItemDialogProps) => {
// 关闭动画期间 item 已为 null,内容沿用最后一次的资源,避免文字消失
const [shown, setShown] = useState(item);
if (item && item !== shown) setShown(item);
export const DeleteItemDialog = ({ items, onClose }: DeleteItemDialogProps) => {
// 关闭动画期间 items 已清空,内容沿用最后一次的资源,避免文字消失
const [shown, setShown] = useState(items);
if (items.length > 0 && items !== shown) setShown(items);
const open = item !== null;
const { mutate, isPending } = useDeleteLibraryItem(onClose);
const open = items.length > 0;
const { mutate, isPending } = useDeleteLibraryItems(onClose);
// 手机返回键先关闭弹窗,不离开页面
useCloseOnBack(open, onClose);
@@ -44,11 +47,23 @@ export const DeleteItemDialog = ({ item, onClose }: DeleteItemDialogProps) => {
>
<AlertDialogContent size="sm">
<AlertDialogHeader>
<AlertDialogTitle>删除资源</AlertDialogTitle>
<AlertDialogTitle>
{shown.length > 1 ? `删除 ${shown.length} 个资源` : "删除资源"}
</AlertDialogTitle>
<AlertDialogDescription>
将从 TorBox 永久删除
<span className="break-all text-foreground">「{shown?.name}」</span>
及其 {shown?.files.length ?? 0} 个文件,同时清除图片缓存,无法恢复。
{shown.slice(0, MAX_LISTED).map((item) => (
<span
key={`${item.source}-${item.id}`}
className="block break-all text-foreground"
>
「{item.name}」
</span>
))}
{shown.length > MAX_LISTED && (
<span className="block">等共 {shown.length} 个资源</span>
)}
及其全部文件(包括已标记删除的),同时清除图片缓存,无法恢复。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -57,7 +72,7 @@ export const DeleteItemDialog = ({ item, onClose }: DeleteItemDialogProps) => {
variant="destructive"
disabled={isPending}
onClick={() =>
shown && mutate({ source: shown.source, id: shown.id })
mutate(shown.map(({ source, id }) => ({ source, id })))
}
>
{isPending && <Spinner data-icon="inline-start" />}
+136 -16
View File
@@ -1,14 +1,27 @@
// 资源条目标题行
"use client";
import { ChevronRightIcon, Trash2Icon } from "lucide-react";
import { ChevronRightIcon, ListChecksIcon, Trash2Icon } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import { SOURCE_LABELS } from "@/constants/torbox";
import { useMediaQuery } from "@/hooks/use-media-query";
import type { LibraryItemView } from "@/hooks/use-library";
import type { LibraryFileView, LibraryItemView } from "@/hooks/use-library";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import type { LibraryFileRef, LibraryItemRef } from "@/types/torbox";
import { formatBytes, formatCount } from "@/utils/format";
import { getItemRefKey } from "@/utils/selection";
import { ExpiryBadge } from "./expiry-badge";
import { ItemDownloadStatus } from "./item-download-status";
@@ -48,6 +61,90 @@ const ItemSummary = ({ item }: { item: LibraryItemView }) => {
);
};
const toFileRefs = (
item: LibraryItemRef,
files: LibraryFileView[]
): LibraryFileRef[] =>
files.map((file) => ({ source: item.source, id: item.id, fileId: file.id }));
/** 选择模式下的快捷选择:只作用于当前显示的文件(已按筛选、范围过滤) */
const QuickSelectMenu = ({ item }: { item: LibraryItemView }) => {
const { selectFiles, deselectFiles, invertFiles } =
useLibrarySelectionStore.getState();
const hasAd = item.files.some((file) => file.isAd);
const hasImage = item.files.some((file) => file.kind === "image");
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label="快捷选择"
title="快捷选择"
className="shrink-0 text-muted-foreground"
// 窄屏整行点击会展开收起,菜单按钮不触发
onClick={(event) => event.stopPropagation()}
/>
}
>
<ListChecksIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuLabel>本组文件</DropdownMenuLabel>
<DropdownMenuItem
disabled={item.files.length === 0}
onClick={() => selectFiles(toFileRefs(item, item.files))}
>
选中全部
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasImage}
onClick={() =>
selectFiles(
toFileRefs(
item,
item.files.filter((file) => file.kind === "image")
)
)
}
>
选中图片
</DropdownMenuItem>
<DropdownMenuItem
disabled={!hasAd}
onClick={() =>
selectFiles(
toFileRefs(
item,
item.files.filter((file) => file.isAd)
)
)
}
>
选中疑似广告
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={item.files.length === 0}
onClick={() => invertFiles(toFileRefs(item, item.files))}
>
反选
</DropdownMenuItem>
<DropdownMenuItem
disabled={item.files.length === 0}
onClick={() => deselectFiles(toFileRefs(item, item.files))}
>
取消选择
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
};
/** 资源分组标题行:虚拟列表中标题与卡片行相互独立,折叠状态由外部控制 */
export const ItemHeader = ({
item,
@@ -57,6 +154,10 @@ export const ItemHeader = ({
}: ItemHeaderProps) => {
// 与 Tailwind sm 断点一致;窄屏整行可点击展开收起,按钮点击冒泡到整行,避免重复触发
const mobile = useMediaQuery("(width < 40rem)");
const selecting = useLibrarySelectionStore((state) => state.active);
const selected = useLibrarySelectionStore(
(state) => getItemRefKey(item) in state.items
);
return (
<div
@@ -74,6 +175,21 @@ export const ItemHeader = ({
>
<ChevronRightIcon className="transition-transform" />
</Button>
{/* 选择模式:勾选整个资源(用于永久删除),与卡片上的文件勾选相互独立 */}
{selecting && (
<Checkbox
checked={selected}
aria-label="选中此资源(永久删除)"
title="选中此资源(永久删除)"
// 窄屏整行点击会展开收起,勾选框不触发
onClick={(event) => event.stopPropagation()}
onCheckedChange={() =>
useLibrarySelectionStore
.getState()
.toggleItem({ source: item.source, id: item.id })
}
/>
)}
{/* 窄屏只留来源色点,把空间让给标题 */}
<Badge
variant="outline"
@@ -90,20 +206,24 @@ export const ItemHeader = ({
<ItemDownloadStatus item={item} />
<ExpiryBadge item={item} />
<ItemSummary item={item} />
<Button
variant="ghost"
size="icon-sm"
aria-label="删除"
title="删除"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={(event) => {
// 窄屏整行点击会展开收起,删除按钮不触发
event.stopPropagation();
onDelete();
}}
>
<Trash2Icon />
</Button>
{selecting ? (
<QuickSelectMenu item={item} />
) : (
<Button
variant="ghost"
size="icon-sm"
aria-label="删除"
title="删除"
className="shrink-0 text-muted-foreground hover:text-destructive"
onClick={(event) => {
// 窄屏整行点击会展开收起,删除按钮不触发
event.stopPropagation();
onDelete();
}}
>
<Trash2Icon />
</Button>
)}
</div>
);
};
@@ -1,4 +1,4 @@
// 资源列表的三个筛选项(来源、下载状态、文件类型):宽屏内联在工具栏,手机放进顶栏菜单
// 资源列表的四个筛选项(来源、下载状态、文件类型、显示范围):宽屏内联在工具栏,手机放进顶栏菜单
"use client";
import { AnimatedNumber } from "@/components/animated-number";
@@ -9,6 +9,7 @@ import { cn } from "@/lib/utils";
import {
useLibraryFilterStore,
type KindFilter,
type ScopeFilter,
type SourceFilter,
type StatusFilter
} from "@/stores/library-filter-store";
@@ -34,6 +35,11 @@ const STATUS_OPTIONS: { value: StatusFilter; label: string }[] = [
{ value: "airlocked", label: "AirLock" }
];
const SCOPE_OPTIONS: { value: ScopeFilter; label: string }[] = [
{ value: "normal", label: "正常" },
{ value: "trash", label: "回收站" }
];
type FilterLayout = "inline" | "stacked";
type FilterGroupProps<T extends string> = {
@@ -84,19 +90,24 @@ const FilterGroup = <T extends string>({
type LibraryFiltersProps = {
/** 各来源在关键词、类型、状态筛选后的资源数 */
sourceCounts: Record<SourceFilter, number>;
/** 回收站里的文件数 */
hiddenCount: number;
layout?: FilterLayout;
};
export const LibraryFilters = ({
sourceCounts,
hiddenCount,
layout = "inline"
}: LibraryFiltersProps) => {
const source = useLibraryFilterStore((state) => state.source);
const kind = useLibraryFilterStore((state) => state.kind);
const status = useLibraryFilterStore((state) => state.status);
const setSource = useLibraryFilterStore((state) => state.setSource);
const scope = useLibraryFilterStore((state) => state.scope);
const setKind = useLibraryFilterStore((state) => state.setKind);
const setStatus = useLibraryFilterStore((state) => state.setStatus);
const setScope = useLibraryFilterStore((state) => state.setScope);
return (
<>
@@ -155,6 +166,27 @@ export const LibraryFilters = ({
</ToggleGroupItem>
))}
</FilterGroup>
<FilterGroup
layout={layout}
title="显示范围"
columns="grid-cols-2"
value={scope}
onChange={setScope}
>
{SCOPE_OPTIONS.map((option) => (
<ToggleGroupItem key={option.value} value={option.value}>
{option.label}
{option.value === "trash" && (
<AnimatedNumber
value={hiddenCount}
format={formatInteger}
className="font-mono text-muted-foreground tabular-nums"
/>
)}
</ToggleGroupItem>
))}
</FilterGroup>
</>
);
};
@@ -25,19 +25,23 @@ import { RETICLE } from "@/constants/motion";
import { useElementWidth } from "@/hooks/use-element-width";
import { useFollowHighlight } from "@/hooks/use-follow-highlight";
import { useGridKeyboardNav } from "@/hooks/use-grid-keyboard-nav";
import type { LibraryItemView } from "@/hooks/use-library";
import {
useRestoreLibraryFiles,
type LibraryItemView
} from "@/hooks/use-library";
import { useStuck } from "@/hooks/use-stuck";
import { cn } from "@/lib/utils";
import { useLibraryFilterStore } from "@/stores/library-filter-store";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import {
getExpandedChecker,
useLibraryViewStore,
type LibraryScrollSnapshot
} from "@/stores/library-view-store";
import type { SourceType } from "@/types/torbox";
import type { LibraryFileRef, SourceType } from "@/types/torbox";
import type { MediaKind } from "@/utils/media";
import { getRangeRefs } from "@/utils/selection";
import { DeleteItemDialog } from "../item/delete-item-dialog";
import { FileCard } from "../file/file-card";
import { FileGrid, FileGridItem } from "../file/file-grid";
import { ItemHeader } from "../item/item-header";
@@ -85,6 +89,8 @@ type LibraryListProps = {
toolbarInset: number;
/** 工具栏位移:展开为 0,收起为 -toolbarInset;露出高度 = max(0, toolbarInset + toolbarY) */
toolbarY: MotionValue<number>;
/** 点标题行的垃圾桶:永久删除该资源,确认弹窗由页面持有(标题行随虚拟滚动卸载) */
onDeleteItem: (item: LibraryItemView) => void;
};
/** 记录视口顶部(工具栏下沿)的行与偏移,供刷新页面后按锚点恢复 */
@@ -170,6 +176,8 @@ type RowViewProps = {
onToggle: (row: HeaderRow, index: number) => void;
onOpen: (file: OpenedFile) => void;
onDelete: (item: LibraryItemView) => void;
onSelect: (ref: LibraryFileRef, shift: boolean) => void;
onRestore: (ref: LibraryFileRef) => void;
};
/** 单行;memo 后滚动只渲染新进入范围的行,已渲染的行不重复渲染 */
@@ -184,7 +192,9 @@ const RowView = memo(function RowView({
measure,
onToggle,
onOpen,
onDelete
onDelete,
onSelect,
onRestore
}: RowViewProps) {
// 边界按工具栏展开时算:收起后吸在更高处的标题仍在边界之上;刚要吸附的标题会提前判为吸附,
// 而吸附样式(两侧外扩、层级)在标题未吸附时看不出差别
@@ -223,9 +233,12 @@ const RowView = memo(function RowView({
<FileGridItem key={file.id} {...CARD_PROPS}>
<FileCard
file={file}
itemRef={row.itemRef}
token={token}
status={row.status}
played={file.id === playedFileId}
onSelect={onSelect}
onRestore={onRestore}
onOpen={() =>
onOpen({
itemKey: row.itemKey,
@@ -266,6 +279,7 @@ const VirtualRows = ({
token,
toolbarInset,
toolbarY,
onDeleteItem,
width
}: LibraryListProps & { width: number }) => {
const columns = getColumnCount(width);
@@ -281,6 +295,7 @@ const VirtualRows = ({
state.source,
state.kind,
state.status,
state.scope,
state.sort,
state.order
])
@@ -434,9 +449,33 @@ const VirtualRows = ({
// 全屏、缩放窗口会改变列数并重建卡片行,放在卡片里会被卸载、中断播放
const [opened, setOpened] = useState<OpenedFile | null>(null);
const closeOpened = useCallback(() => setOpened(null), []);
// 删除确认弹窗同样只渲染一个:标题行随虚拟滚动卸载,不能把弹窗放在行里
const [deleting, setDeleting] = useState<LibraryItemView | null>(null);
const closeDeleting = useCallback(() => setDeleting(null), []);
// 选择模式:Shift 连选按列表中显示的顺序(只含已展开资源的文件)
const orderedFiles = useMemo(
() =>
rows.flatMap((row) =>
row.type === "cards"
? row.files.map((file) => ({ ...row.itemRef, fileId: file.id }))
: []
),
[rows]
);
const orderedFilesRef = useRef(orderedFiles);
useEffect(() => {
orderedFilesRef.current = orderedFiles;
});
const selectFile = useCallback((ref: LibraryFileRef, shift: boolean) => {
const { anchor, toggleFile, selectFiles } =
useLibrarySelectionStore.getState();
if (shift && anchor)
selectFiles(getRangeRefs(orderedFilesRef.current, anchor, ref), ref);
else toggleFile(ref);
}, []);
// 回收站里恢复单个文件
const { mutate: restoreFiles } = useRestoreLibraryFiles();
const restoreFile = useCallback(
(ref: LibraryFileRef) => restoreFiles([ref]),
[restoreFiles]
);
// 最近播放的视频:关掉弹窗后封面保持高亮,方便找回刚看的是哪个;换一个视频后转移过去
const [lastPlayed, setLastPlayed] = useState<LibraryFileTarget | null>(null);
const openFile = useCallback((file: OpenedFile) => {
@@ -515,7 +554,9 @@ const VirtualRows = ({
measure={virtualizer.measureElement}
onToggle={onToggleHeader}
onOpen={openFile}
onDelete={setDeleting}
onDelete={onDeleteItem}
onSelect={selectFile}
onRestore={restoreFile}
/>
);
};
@@ -565,7 +606,6 @@ const VirtualRows = ({
onTargetChange={(target) => openFile({ ...target, kind: "video" })}
onClose={closeOpened}
/>
<DeleteItemDialog item={deleting} onClose={closeDeleting} />
</>
);
};
@@ -2,7 +2,11 @@
import type { VirtualItem } from "@tanstack/react-virtual";
import type { LibraryFileView, LibraryItemView } from "@/hooks/use-library";
import type { DownloadStatus, SourceType } from "@/types/torbox";
import type {
DownloadStatus,
LibraryItemRef,
SourceType
} from "@/types/torbox";
/** 卡片网格参数:与 FileGrid 的 gap-3 保持一致 */
export const GRID_GAP = 12;
@@ -35,6 +39,8 @@ export type LibraryRow =
key: string;
/** 所属资源分组,图片预览只在这一组内切换 */
itemKey: string;
/** 所属资源,文件的操作(选择、标记删除)以它加文件 id 定位 */
itemRef: LibraryItemRef;
files: LibraryFileView[];
status: DownloadStatus;
}
@@ -70,6 +76,7 @@ export const buildLibraryRows = (
for (const item of items) {
const itemKey = getItemKey(item);
const expanded = isExpanded(itemKey);
const itemRef = { source: item.source, id: item.id };
rows.push({
type: "header",
key: `header:${itemKey}`,
@@ -89,6 +96,7 @@ export const buildLibraryRows = (
type: "cards",
key: `cards:${itemKey}:${start / columns}`,
itemKey,
itemRef,
files: item.files.slice(start, start + columns),
status: item.status
});
@@ -138,8 +146,7 @@ export type RowPiece =
/** 顶层片段:占位块、不属于分组的行,或包住一个资源分组的容器 */
export type RowBlock =
| RowPiece
| { type: "group"; key: string; pieces: RowPiece[] };
RowPiece | { type: "group"; key: string; pieces: RowPiece[] };
const pushSpacer = (
target: RowPiece[] | RowBlock[],
@@ -0,0 +1,153 @@
// 选择模式的底部操作栏:已选数量、快速选择、标记删除 / 恢复 / 永久删除
"use client";
import { useEffect, useMemo } from "react";
import { Trash2Icon, Undo2Icon, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
import {
useHideLibraryFiles,
useRestoreLibraryFiles,
type LibraryItemView
} from "@/hooks/use-library";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import type { ScopeFilter } from "@/stores/library-filter-store";
import type { LibraryFileRef } from "@/types/torbox";
import { getFileRefKey, getItemRefKey } from "@/utils/selection";
type LibrarySelectionBarProps = {
/** 列表当前显示的资源(已筛选):只有看得见的才算已选,避免操作到筛掉的文件 */
items: LibraryItemView[];
scope: ScopeFilter;
/** 永久删除选中的资源,确认弹窗由页面持有 */
onDeleteItems: (items: LibraryItemView[]) => void;
};
export const LibrarySelectionBar = ({
items,
scope,
onDeleteItems
}: LibrarySelectionBarProps) => {
const active = useLibrarySelectionStore((state) => state.active);
const selectedFiles = useLibrarySelectionStore((state) => state.files);
const selectedItems = useLibrarySelectionStore((state) => state.items);
const hide = useHideLibraryFiles();
const restore = useRestoreLibraryFiles();
// Esc 退出选择模式;有弹窗、灯箱、播放器打开时留给它们自己处理
useEffect(() => {
if (!active) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Escape" || event.defaultPrevented) return;
if (document.querySelector('[role="dialog"], [role="alertdialog"]'))
return;
useLibrarySelectionStore.getState().setActive(false);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [active]);
const visible = useMemo(() => {
const files: LibraryFileRef[] = [];
const ads: LibraryFileRef[] = [];
for (const item of items)
for (const file of item.files) {
const ref = { source: item.source, id: item.id, fileId: file.id };
files.push(ref);
if (file.isAd) ads.push(ref);
}
return { files, ads };
}, [items]);
const chosenFiles = useMemo(
() => visible.files.filter((ref) => getFileRefKey(ref) in selectedFiles),
[visible, selectedFiles]
);
const chosenItems = useMemo(
() => items.filter((item) => getItemRefKey(item) in selectedItems),
[items, selectedItems]
);
if (!active) return null;
const { setActive, selectFiles, clear } = useLibrarySelectionStore.getState();
const trash = scope === "trash";
const pending = hide.isPending || restore.isPending;
const mutation = trash ? restore : hide;
return (
<div className="shrink-0 border-t bg-background">
<div className="mx-auto flex w-full flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3 sm:max-w-[1920px] md:px-6">
<span
className="font-mono text-sm tabular-nums"
aria-live="polite"
role="status"
>
已选 {chosenFiles.length} 个文件
{chosenItems.length > 0 && ` · ${chosenItems.length} 个资源`}
</span>
<div className="flex flex-wrap items-center gap-1">
<Button
variant="ghost"
size="sm"
disabled={visible.files.length === 0}
onClick={() => selectFiles(visible.files)}
>
全选
</Button>
<Button
variant="ghost"
size="sm"
disabled={visible.ads.length === 0}
onClick={() => selectFiles(visible.ads)}
>
选中疑似广告
<span className="font-mono text-muted-foreground tabular-nums">
{visible.ads.length}
</span>
</Button>
<Button
variant="ghost"
size="sm"
disabled={chosenFiles.length === 0 && chosenItems.length === 0}
onClick={clear}
>
清空
</Button>
</div>
<div className="ml-auto flex flex-wrap items-center gap-2">
{chosenItems.length > 0 && (
<Button
variant="destructive"
size="sm"
disabled={pending}
onClick={() => onDeleteItems(chosenItems)}
>
<Trash2Icon data-icon="inline-start" />
永久删除 {chosenItems.length} 个资源
</Button>
)}
<Button
size="sm"
disabled={chosenFiles.length === 0 || pending}
onClick={() => mutation.mutate(chosenFiles)}
>
{pending ? (
<Spinner data-icon="inline-start" />
) : trash ? (
<Undo2Icon data-icon="inline-start" />
) : (
<Trash2Icon data-icon="inline-start" />
)}
{trash ? "恢复" : "标记删除"}
</Button>
<Button variant="outline" size="sm" onClick={() => setActive(false)}>
<XIcon data-icon="inline-start" />
退出
</Button>
</div>
</div>
</div>
);
};
@@ -9,6 +9,7 @@ import {
ChevronDownIcon,
ChevronsDownUpIcon,
ChevronsUpDownIcon,
ListChecksIcon,
RefreshCwIcon,
SearchIcon,
XIcon
@@ -46,6 +47,7 @@ import {
type LibrarySort,
type SourceFilter
} from "@/stores/library-filter-store";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import { useLibraryViewStore } from "@/stores/library-view-store";
import type { SortOrder } from "@/types/search";
import { formatBytes, formatCount } from "@/utils/format";
@@ -170,8 +172,37 @@ const ExpandAllButton = ({
);
};
/** 进入 / 退出选择模式:卡片点击改为勾选,批量标记删除或永久删除 */
const SelectModeButton = ({ disabled }: { disabled: boolean }) => {
const active = useLibrarySelectionStore((state) => state.active);
const setActive = useLibrarySelectionStore((state) => state.setActive);
const label = active ? "退出选择" : "选择";
return (
<Tooltip>
<TooltipTrigger
render={
<Button
variant={active ? "secondary" : "outline"}
size="icon-sm"
aria-label={label}
aria-pressed={active}
disabled={disabled && !active}
onClick={() => setActive(!active)}
/>
}
>
<ListChecksIcon />
</TooltipTrigger>
<TooltipContent side="bottom">{label}</TooltipContent>
</Tooltip>
);
};
type LibraryToolbarProps = {
sourceCounts: Record<SourceFilter, number>;
/** 回收站里的文件数 */
hiddenCount: number;
summary: { items: number; files: number; size: number };
/** 当前结果中是否有展开的资源 */
anyExpanded: boolean;
@@ -183,6 +214,7 @@ type LibraryToolbarProps = {
export const LibraryToolbar = ({
sourceCounts,
hiddenCount,
summary,
anyExpanded,
isFetching,
@@ -237,7 +269,10 @@ export const LibraryToolbar = ({
{/* 手机宽度下三个筛选项收进顶栏菜单(MobileMenu) */}
<div className="hidden md:contents">
<LibraryFilters sourceCounts={sourceCounts} />
<LibraryFilters
sourceCounts={sourceCounts}
hiddenCount={hiddenCount}
/>
</div>
<SortMenu />
@@ -245,6 +280,7 @@ export const LibraryToolbar = ({
anyExpanded={anyExpanded}
disabled={summary.items === 0}
/>
<SelectModeButton disabled={summary.items === 0} />
<div className="ml-auto flex items-center gap-2">
<span className="font-mono text-xs text-muted-foreground tabular-nums">
+115 -57
View File
@@ -1,8 +1,13 @@
// 资源库视图(数据获取与状态分发)
"use client";
import { useMemo, useRef, useState } from "react";
import { FolderOpenIcon, SearchXIcon, TriangleAlertIcon } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
FolderOpenIcon,
SearchXIcon,
Trash2Icon,
TriangleAlertIcon
} from "lucide-react";
import {
motion,
type MotionStyle,
@@ -29,25 +34,40 @@ import {
import { TOOLBAR_COLLAPSE } from "@/constants/motion";
import { useElementHeight } from "@/hooks/use-element-height";
import { useHideOnScroll } from "@/hooks/use-hide-on-scroll";
import { useFilteredLibrary, useRefreshLibrary } from "@/hooks/use-library";
import {
useFilteredLibrary,
useRefreshLibrary,
type LibraryItemView
} from "@/hooks/use-library";
import { useMediaQuery } from "@/hooks/use-media-query";
import { useLibraryFilterStore } from "@/stores/library-filter-store";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import {
getExpandedChecker,
useLibraryViewStore
} from "@/stores/library-view-store";
import type { SourceType } from "@/types/torbox";
import { DeleteItemDialog } from "../item/delete-item-dialog";
import { BackToTopButton } from "./back-to-top-button";
import { LibraryList } from "./library-list";
import { getItemKey } from "./library-rows";
import { LibrarySelectionBar } from "./library-selection-bar";
import { LibrarySkeleton } from "./library-skeleton";
import { LibraryToolbar } from "./library-toolbar";
import { SourceNotice } from "./source-notice";
export const LibraryView = ({ token }: { token: string }) => {
const { query, items, sourceCounts, summary, isFiltered, isEmptyLibrary } =
useFilteredLibrary();
const {
query,
items,
sourceCounts,
hiddenCount,
scope,
summary,
isFiltered,
isEmptyLibrary
} = useFilteredLibrary();
const refresh = useRefreshLibrary();
const resetFilter = useLibraryFilterStore((state) => state.reset);
const allExpanded = useLibraryViewStore((state) => state.allExpanded);
@@ -56,6 +76,16 @@ export const LibraryView = ({ token }: { token: string }) => {
const isExpanded = getExpandedChecker({ allExpanded, toggled });
return items.some((item) => isExpanded(getItemKey(item)));
}, [items, allExpanded, toggled]);
// 永久删除确认弹窗只渲染一个:标题行随虚拟滚动卸载,不能把弹窗放在行里;
// 标题行的垃圾桶与选择模式的操作栏共用
const [deleting, setDeleting] = useState<LibraryItemView[]>([]);
const closeDeleting = useCallback(() => setDeleting([]), []);
const deleteItem = useCallback(
(item: LibraryItemView) => setDeleting([item]),
[]
);
// 切换显示范围后,原来选中的文件不再显示,清空已选
useEffect(() => useLibrarySelectionStore.getState().clear(), [scope]);
// 滚动容器由页面持有,虚拟列表只负责计算渲染哪些行
const scrollRef = useRef<HTMLDivElement>(null);
@@ -114,7 +144,24 @@ export const LibraryView = ({ token }: { token: string }) => {
))}
</div>
)}
{isEmptyLibrary ? (
{scope === "trash" && hiddenCount === 0 ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
<Trash2Icon />
</EmptyMedia>
<EmptyTitle>回收站是空的</EmptyTitle>
<EmptyDescription>
在选择模式下标记删除的文件会出现在这里,可随时恢复
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button variant="outline" size="sm" onClick={resetFilter}>
返回资源列表
</Button>
</EmptyContent>
</Empty>
) : isEmptyLibrary ? (
<Empty>
<EmptyHeader>
<EmptyMedia variant="icon">
@@ -151,61 +198,72 @@ export const LibraryView = ({ token }: { token: string }) => {
}
return (
// 手机宽度下工具栏与滚动容器叠放在同一格(单格 grid),工具栏在上层;
// 收起时用 transform 上移并被这一层裁掉,看起来滑进顶栏下方
<div className="flex min-h-0 flex-1 flex-col max-md:grid max-md:grid-cols-1 max-md:grid-rows-1 max-md:overflow-hidden max-md:*:col-start-1 max-md:*:row-start-1">
<motion.div
ref={toolbarRef}
className="shrink-0 bg-background max-md:z-20 max-md:self-start"
style={{ y: toolbarY }}
initial={false}
animate={{ y: toolbarHidden ? -toolbarHeight : 0 }}
transition={reduced ? { duration: 0 } : TOOLBAR_COLLAPSE.transition}
inert={toolbarHidden}
>
<LibraryToolbar
sourceCounts={sourceCounts}
summary={summary}
anyExpanded={anyExpanded}
isFetching={query.isFetching}
onRefresh={refresh}
onSearchFocusChange={setSearchFocused}
/>
</motion.div>
{/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */}
{/* relative 容器是返回顶部按钮的定位参照,按钮不随滚动容器的内容移动 */}
<div className="relative flex min-h-0 flex-1 flex-col">
// 选择模式的操作栏在列表下方,不参与下面的叠层
<div className="flex min-h-0 flex-1 flex-col">
{/* 手机宽度下工具栏与滚动容器叠放在同一格(单格 grid),工具栏在上层;
收起时用 transform 上移并被这一层裁掉,看起来滑进顶栏下方 */}
<div className="flex min-h-0 flex-1 flex-col max-md:grid max-md:grid-cols-1 max-md:grid-rows-1 max-md:overflow-hidden max-md:*:col-start-1 max-md:*:row-start-1">
<motion.div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto"
style={
{
// MotionStyle 未声明 CSS 变量,运行时按 MotionValue 逐帧写入
"--toolbar-inset": stickyInset,
scrollPaddingTop: `calc(3.5rem + ${toolbarInset}px)`
} as MotionStyle
}
ref={toolbarRef}
className="shrink-0 bg-background max-md:z-20 max-md:self-start"
style={{ y: toolbarY }}
initial={false}
animate={{ y: toolbarHidden ? -toolbarHeight : 0 }}
transition={reduced ? { duration: 0 } : TOOLBAR_COLLAPSE.transition}
inert={toolbarHidden}
>
{content ? (
<div
className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6"
style={{ marginTop: toolbarInset }}
>
{content}
</div>
) : (
<LibraryList
scrollRef={scrollRef}
items={items}
notices={notices}
token={token}
toolbarInset={toolbarInset}
toolbarY={toolbarY}
/>
)}
<LibraryToolbar
sourceCounts={sourceCounts}
hiddenCount={hiddenCount}
summary={summary}
anyExpanded={anyExpanded}
isFetching={query.isFetching}
onRefresh={refresh}
onSearchFocusChange={setSearchFocused}
/>
</motion.div>
<BackToTopButton scrollRef={scrollRef} />
{/* Tab 聚焦时浏览器自动滚动会避开工具栏与资源列表的吸顶标题条(3.5rem) */}
{/* relative 容器是返回顶部按钮的定位参照,按钮不随滚动容器的内容移动 */}
<div className="relative flex min-h-0 flex-1 flex-col">
<motion.div
ref={scrollRef}
className="min-h-0 flex-1 overflow-y-auto"
style={
{
// MotionStyle 未声明 CSS 变量,运行时按 MotionValue 逐帧写入
"--toolbar-inset": stickyInset,
scrollPaddingTop: `calc(3.5rem + ${toolbarInset}px)`
} as MotionStyle
}
>
{content ? (
<div
className="mx-auto flex w-full sm:max-w-[1920px] flex-col gap-8 p-4 md:p-6"
style={{ marginTop: toolbarInset }}
>
{content}
</div>
) : (
<LibraryList
scrollRef={scrollRef}
items={items}
notices={notices}
token={token}
toolbarInset={toolbarInset}
toolbarY={toolbarY}
onDeleteItem={deleteItem}
/>
)}
</motion.div>
<BackToTopButton scrollRef={scrollRef} />
</div>
</div>
<LibrarySelectionBar
items={items}
scope={scope}
onDeleteItems={setDeleting}
/>
<DeleteItemDialog items={deleting} onClose={closeDeleting} />
</div>
);
};
-42
View File
@@ -1,42 +0,0 @@
// 删除资源接口:从 TorBox 永久删除,并清掉它的图片缓存
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import { removeCachedImages } from "@/server/image-cache";
import { createLogger } from "@/server/logger";
import {
jsonError,
jsonOk,
jsonUpstreamError,
withApi
} from "@/server/response";
import { deleteItem, isSourceType } from "@/server/torbox";
const log = createLogger("library");
export const dynamic = "force-dynamic";
type Ctx = { params: Promise<{ type: string; id: string }> };
export const DELETE = withApi(async (req: NextRequest, { params }: Ctx) => {
const denied = guard(req);
if (denied) return denied;
const { type, id } = await params;
if (!isSourceType(type) || !/^\d+$/.test(id))
return jsonError(404, "unknown source");
try {
await deleteItem(type, id);
} catch (error) {
// TorBox 的失败原因(资源不存在、限流等)对用户有意义,原样透出
log.error({ type, id, err: error }, "删除资源失败");
return jsonUpstreamError(error);
}
log.info({ type, id }, "已删除资源");
// TorBox 已删除,缓存清理失败不影响结果,残留的图片会按保留时间淘汰
await removeCachedImages(type, id).catch((error) =>
log.warn({ type, id, err: error }, "清理图片缓存失败")
);
return jsonOk();
});
+54
View File
@@ -0,0 +1,54 @@
// 批量永久删除资源接口:逐个从 TorBox 删除,并清掉各自的图片缓存
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import { MAX_BATCH, parseItemRefs } from "@/server/hidden-files";
import { removeCachedImages } from "@/server/image-cache";
import { createLogger } from "@/server/logger";
import { jsonError, jsonOk, withApi } from "@/server/response";
import { deleteItem } from "@/server/torbox";
import { RateLimitError } from "@/server/torbox-limiter";
import type { DeleteItemsResult } from "@/types/torbox";
const log = createLogger("library");
export const dynamic = "force-dynamic";
export const POST = withApi(async (req: NextRequest) => {
const denied = guard(req);
if (denied) return denied;
const items = parseItemRefs(await req.json().catch(() => null));
if (!items) return jsonError(400, `items 须为 1–${MAX_BATCH} 个 {source,id}`);
const result: DeleteItemsResult = { deleted: [], failed: [] };
// 额度用尽后其余项不再请求,直接记为失败,前端据此提示稍后重试
let limited: string | null = null;
// 请求都经过 TorBox 限流队列,顺序执行即可
for (const item of items) {
if (limited) {
result.failed.push({ ...item, message: limited });
continue;
}
try {
await deleteItem(item.source, String(item.id));
} catch (error) {
// TorBox 的失败原因(资源不存在、限流等)对用户有意义,原样透出
log.error({ ...item, err: error }, "删除资源失败");
const message = error instanceof Error ? error.message : "删除失败";
if (error instanceof RateLimitError) limited = message;
result.failed.push({ ...item, message });
continue;
}
result.deleted.push(item);
// TorBox 已删除,缓存清理失败不影响结果,残留的图片会按保留时间淘汰
await removeCachedImages(item.source, String(item.id)).catch((error) =>
log.warn({ ...item, err: error }, "清理图片缓存失败")
);
}
log.info(
{ deleted: result.deleted.length, failed: result.failed.length },
"批量删除资源"
);
return jsonOk(result);
});
+40
View File
@@ -0,0 +1,40 @@
// 文件标记删除 / 恢复接口:TorBox 不能单独删除资源内的文件,只在本地隐藏
import type { NextRequest } from "next/server";
import { guard } from "@/server/auth";
import {
hideFiles,
MAX_BATCH,
parseFileRefs,
restoreFiles
} from "@/server/hidden-files";
import { jsonError, jsonOk, withApi } from "@/server/response";
export const dynamic = "force-dynamic";
const parse = async (req: NextRequest) => {
const body = await req.json().catch(() => null);
return parseFileRefs(body);
};
const invalid = () =>
jsonError(400, `files 须为 1–${MAX_BATCH} 个 {source,id,fileId}`);
/** 标记删除,同时清掉这些文件的图片缓存 */
export const POST = withApi(async (req: NextRequest) => {
const denied = guard(req);
if (denied) return denied;
const files = await parse(req);
if (!files) return invalid();
await hideFiles(files);
return jsonOk();
});
/** 恢复 */
export const DELETE = withApi(async (req: NextRequest) => {
const denied = guard(req);
if (denied) return denied;
const files = await parse(req);
if (!files) return invalid();
restoreFiles(files);
return jsonOk();
});
+2
View File
@@ -23,6 +23,8 @@ export const GET = withApi(async (req: NextRequest) => {
for (const item of items) {
if (item.status !== "ready") continue;
for (const file of item.files) {
// 已标记删除的文件不收录
if (file.hidden) continue;
// 只收录视频,字幕、压缩包、文本等传给播放器没有意义
const name = getBaseName(file.short_name ?? file.name);
if (getMediaKind(name, file.mimetype) !== "video") continue;
@@ -9,6 +9,7 @@ import {
getCachedImage,
imageCacheKey
} from "@/server/image-cache";
import { isFileHidden } from "@/server/hidden-files";
import { createLogger, mediaRef } from "@/server/logger";
import { getMockImage } from "@/server/mock-library";
import { jsonError, jsonUpstreamError, withApi } from "@/server/response";
@@ -80,6 +81,9 @@ const proxy = async (
return jsonError(404, "unknown source");
}
// 已标记删除的文件:旧链接、播放列表和加载到一半的图片都拿不到,也不会把缓存写回去
if (isFileHidden(type, id, fileId)) return jsonError(404, "file removed");
// 演示模式:图片返回生成的占位图,其他文件仍提示不可播放
const mockImage = isMockMode()
? getMockImage(type, Number(id), Number(fileId))
+28
View File
@@ -0,0 +1,28 @@
"use client"
import { Checkbox as CheckboxPrimitive } from "@base-ui/react/checkbox"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({ className, ...props }: CheckboxPrimitive.Root.Props) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 group-has-[:focus-visible]/field-label:ring-0 group-has-[:focus-visible]/field-label:not-data-checked:border-input after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground group-has-[:focus-visible]/field-label:data-checked:border-primary dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+6
View File
@@ -15,3 +15,9 @@ export const LIBRARY_SORT_OPTIONS: {
{ value: "date", label: "日期", defaultOrder: "desc" },
{ value: "size", label: "大小", defaultOrder: "desc" }
];
/**
* 磁力资源里夹带的广告文件的扩展名(小写、不带点)。
* 选择模式下可一键选中这类文件再标记删除;发现新的广告后缀往这里加即可
*/
export const AD_FILE_EXT = new Set(["html", "htm", "chm"]);
+101 -24
View File
@@ -4,7 +4,13 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { NEW_ITEM_WINDOW_MS } from "@/constants/torbox";
import { message } from "@/lib/message";
import { deleteLibraryItem, getLibrary } from "@/services/library";
import {
deleteLibraryItems,
getLibrary,
hideLibraryFiles,
restoreLibraryFiles
} from "@/services/library";
import { useLibrarySelectionStore } from "@/stores/library-selection-store";
import {
useLibraryFilterStore,
type LibrarySort,
@@ -13,17 +19,20 @@ import {
import type { SortOrder } from "@/types/search";
import type {
LibraryFile,
LibraryFileRef,
LibraryItem,
LibraryResponse,
SourceType
LibraryItemRef,
LibraryResponse
} from "@/types/torbox";
import { isWithin, msUntil, toTimestamp } from "@/utils/date";
import {
getBaseName,
getFileExt,
getMediaKind,
isAdExt,
type MediaKind
} from "@/utils/media";
import { getFileRefKey, getItemRefKey } from "@/utils/selection";
import { compareByNumber, compareNatural, type Comparator } from "@/utils/sort";
export type LibraryFileView = LibraryFile & {
@@ -31,6 +40,8 @@ export type LibraryFileView = LibraryFile & {
displayName: string;
ext: string;
kind: MediaKind;
/** 扩展名属于常见的广告文件(AD_FILE_EXT),选择模式下可一键选中 */
isAd: boolean;
};
export type LibraryItemView = Omit<LibraryItem, "files"> & {
@@ -73,34 +84,81 @@ export const useRefreshLibrary = () => {
);
};
type DeleteTarget = { source: SourceType; id: number };
const setLibraryItems = (
queryClient: ReturnType<typeof useQueryClient>,
update: (items: LibraryItem[]) => LibraryItem[]
) =>
queryClient.setQueryData<LibraryResponse>(
LIBRARY_QUERY_KEY,
(data) => data && { ...data, items: update(data.items) }
);
/**
* 删除资源:成功后直接从缓存的列表里移除,不重新拉取——
* TorBox 的列表有缓存,刚删的资源可能还会在里面;失败提示由 axios 拦截器统一处理。
* 批量永久删除资源:成功的直接从缓存的列表里移除,不重新拉取——
* TorBox 的列表有缓存,刚删的资源可能还会在里面;部分失败时提示失败数,失败项保留。
* 请求失败的提示由 axios 拦截器统一处理。
* 回调通过 hook 参数传入而不是 mutate():确认弹窗随时可能卸载,状态仍要正确收尾
*/
export const useDeleteLibraryItem = (onSuccess?: () => void) => {
export const useDeleteLibraryItems = (onSuccess?: () => void) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ source, id }: DeleteTarget) => deleteLibraryItem(source, id),
onSuccess: (_data, { source, id }) => {
queryClient.setQueryData<LibraryResponse>(
LIBRARY_QUERY_KEY,
(data) =>
data && {
...data,
items: data.items.filter(
(item) => !(item.source === source && item.id === id)
)
}
mutationFn: (items: LibraryItemRef[]) => deleteLibraryItems(items),
onSuccess: ({ deleted, failed }) => {
const gone = new Set(deleted.map(getItemRefKey));
setLibraryItems(queryClient, (items) =>
items.filter((item) => !gone.has(getItemRefKey(item)))
);
message.success("已删除");
useLibrarySelectionStore.getState().remove([], deleted);
if (deleted.length)
message.success(
deleted.length === 1 ? "已删除" : `已删除 ${deleted.length} 个资源`
);
if (failed.length)
message.warning(`${failed.length} 个资源删除失败`, {
description: failed[0].message
});
onSuccess?.();
}
});
};
/** 标记删除 / 恢复成功后,直接翻转缓存里这些文件的 hidden 标记 */
const useSetFilesHidden = (hidden: boolean) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (files: LibraryFileRef[]) =>
hidden ? hideLibraryFiles(files) : restoreLibraryFiles(files),
onSuccess: (_data, files) => {
const targets = new Set(files.map(getFileRefKey));
setLibraryItems(queryClient, (items) =>
items.map((item) => ({
...item,
files: item.files.map((file) =>
targets.has(
getFileRefKey({
source: item.source,
id: item.id,
fileId: file.id
})
)
? { ...file, hidden }
: file
)
}))
);
useLibrarySelectionStore.getState().remove(files, []);
message.success(
hidden
? `已标记删除 ${files.length} 个文件`
: `已恢复 ${files.length} 个文件`
);
}
});
};
export const useHideLibraryFiles = () => useSetFilesHidden(true);
export const useRestoreLibraryFiles = () => useSetFilesHidden(false);
const sumSize = (files: LibraryFile[]) =>
files.reduce((total, file) => total + (file.size ?? 0), 0);
@@ -145,6 +203,7 @@ export const useFilteredLibrary = () => {
const source = useLibraryFilterStore((state) => state.source);
const kind = useLibraryFilterStore((state) => state.kind);
const status = useLibraryFilterStore((state) => state.status);
const scope = useLibraryFilterStore((state) => state.scope);
const sort = useLibraryFilterStore((state) => state.sort);
const order = useLibraryFilterStore((state) => state.order);
@@ -157,7 +216,8 @@ export const useFilteredLibrary = () => {
...file,
displayName,
ext: getFileExt(displayName),
kind: getMediaKind(displayName, file.mimetype)
kind: getMediaKind(displayName, file.mimetype),
isAd: isAdExt(getFileExt(displayName))
};
});
// 以数据拉取时间为「现在」,保持渲染纯净
@@ -188,7 +248,12 @@ export const useFilteredLibrary = () => {
if (!item.airlocked) return [];
} else if (status !== "all" && item.status !== status) return [];
const nameHit = !needle || item.name.toLowerCase().includes(needle);
const files = item.files.filter(
// 正常列表只含未标记删除的文件,回收站只含已标记的;
// 文件全被挪到另一边的资源(以及回收站里没有文件的资源)不显示
const trash = scope === "trash";
const inScope = item.files.filter((file) => file.hidden === trash);
if (inScope.length === 0 && (item.files.length > 0 || trash)) return [];
const files = inScope.filter(
(file) =>
(kind === "all" || file.kind === kind) &&
(nameHit || file.name.toLowerCase().includes(needle))
@@ -225,10 +290,22 @@ export const useFilteredLibrary = () => {
Boolean(needle) ||
source !== "all" ||
kind !== "all" ||
status !== "all",
status !== "all" ||
scope !== "normal",
isEmptyLibrary: items.length === 0
};
}, [items, keyword, source, kind, status, sort, order]);
}, [items, keyword, source, kind, status, scope, sort, order]);
return { query, ...view };
/** 回收站里的文件总数,不受其他筛选影响 */
const hiddenCount = useMemo(
() =>
items.reduce(
(total, item) =>
total + item.files.filter((file) => file.hidden).length,
0
),
[items]
);
return { query, hiddenCount, scope, ...view };
};
+20
View File
@@ -33,6 +33,26 @@ export const torboxDeletedItems = sqliteTable(
(table) => [primaryKey({ columns: [table.source, table.id] })]
);
/**
* 被标记删除的文件:TorBox 不能单独删除资源内的某个文件(如磁力里夹带的广告),
* 这里只在本地隐藏,列表、播放列表与流接口都据此跳过,可在回收站恢复
*/
export const torboxHiddenFiles = sqliteTable(
"torbox_hidden_files",
{
/** 来源,如 torrents */
source: text().notNull(),
itemId: integer("item_id").notNull(),
/** 文件 id,只在资源内唯一 */
fileId: integer("file_id").notNull(),
/** 毫秒时间戳 */
hiddenAt: integer("hidden_at").notNull()
},
(table) => [
primaryKey({ columns: [table.source, table.itemId, table.fileId] })
]
);
/** TorBox 请求记录(滑动窗口限流):每发出一次请求,在它扣减的每条额度下各记一行 */
export const torboxRateHits = sqliteTable(
"torbox_rate_hits",
+156
View File
@@ -0,0 +1,156 @@
// 被标记删除的文件:TorBox 不能单独删除资源内的文件,只在本地隐藏(torbox_hidden_files)
import { and, eq, inArray } from "drizzle-orm";
import { db } from "@/server/db";
import { torboxHiddenFiles } from "@/server/db/schema";
import { imageCacheKey, removeCachedFiles } from "@/server/image-cache";
import { createLogger } from "@/server/logger";
import { SOURCES } from "@/constants/torbox";
import type {
LibraryFileRef,
LibraryItemRef,
SourceType
} from "@/types/torbox";
const log = createLogger("hidden-files");
const isSourceType = (value: string): value is SourceType =>
SOURCES.some((source) => source.type === value);
/** 单次请求的数量上限:防止异常输入拖垮数据库 */
export const MAX_BATCH = 2000;
/** 按批删除,避免超出 SQLite 单条语句的变量数上限 */
const PRUNE_BATCH = 500;
/** 文件的 key:来源/资源 id/文件 id,与图片缓存、直链缓存一致 */
export const hiddenKey = (type: SourceType, id: number, fileId: number) =>
imageCacheKey(type, String(id), String(fileId));
const isId = (value: unknown): value is number =>
Number.isSafeInteger(value) && (value as number) >= 0;
/** 校验请求体里的引用列表;格式不对返回 null */
const parseRefs = <T extends LibraryItemRef>(
list: unknown,
withFile: boolean
): T[] | null => {
if (!Array.isArray(list) || list.length === 0 || list.length > MAX_BATCH)
return null;
const refs: T[] = [];
for (const entry of list) {
if (
typeof entry !== "object" ||
entry === null ||
typeof entry.source !== "string" ||
!isSourceType(entry.source) ||
!isId(entry.id) ||
(withFile && !isId(entry.fileId))
)
return null;
refs.push(
(withFile
? { source: entry.source, id: entry.id, fileId: entry.fileId }
: { source: entry.source, id: entry.id }) as T
);
}
return refs;
};
export const parseFileRefs = (body: unknown) =>
parseRefs<LibraryFileRef>((body as { files?: unknown } | null)?.files, true);
export const parseItemRefs = (body: unknown) =>
parseRefs<LibraryItemRef>((body as { items?: unknown } | null)?.items, false);
/** 所有被隐藏的文件 key(`来源/资源 id/文件 id`) */
export const getHiddenKeys = () =>
new Set(
db
.select()
.from(torboxHiddenFiles)
.all()
.map((row) => hiddenKey(row.source as SourceType, row.itemId, row.fileId))
);
export const isFileHidden = (type: SourceType, id: string, fileId: string) =>
db
.select({ fileId: torboxHiddenFiles.fileId })
.from(torboxHiddenFiles)
.where(
and(
eq(torboxHiddenFiles.source, type),
eq(torboxHiddenFiles.itemId, Number(id)),
eq(torboxHiddenFiles.fileId, Number(fileId))
)
)
.get() !== undefined;
/** 标记删除:写库后清掉这些文件的图片缓存,缓存清理失败不影响结果 */
export const hideFiles = async (files: LibraryFileRef[]) => {
const hiddenAt = Date.now();
db.transaction((tx) => {
for (const { source, id, fileId } of files)
tx.insert(torboxHiddenFiles)
.values({ source, itemId: id, fileId, hiddenAt })
.onConflictDoUpdate({
target: [
torboxHiddenFiles.source,
torboxHiddenFiles.itemId,
torboxHiddenFiles.fileId
],
set: { hiddenAt }
})
.run();
});
log.info({ count: files.length }, "已标记删除文件");
await removeCachedFiles(
files.map((file) => hiddenKey(file.source, file.id, file.fileId))
).catch((error) => log.warn({ err: error }, "清理图片缓存失败"));
};
/** 恢复:只去掉隐藏记录,图片缓存下次访问时重新生成 */
export const restoreFiles = (files: LibraryFileRef[]) => {
db.transaction((tx) => {
for (const { source, id, fileId } of files)
tx.delete(torboxHiddenFiles)
.where(
and(
eq(torboxHiddenFiles.source, source),
eq(torboxHiddenFiles.itemId, id),
eq(torboxHiddenFiles.fileId, fileId)
)
)
.run();
});
log.info({ count: files.length }, "已恢复文件");
};
/** 资源从 TorBox 删除后,它的隐藏记录没有意义了 */
export const forgetItemHidden = (type: SourceType, id: number) => {
db.delete(torboxHiddenFiles)
.where(
and(eq(torboxHiddenFiles.source, type), eq(torboxHiddenFiles.itemId, id))
)
.run();
};
/** 某来源完整拉取成功后,清掉已不在列表里的资源留下的隐藏记录 */
export const pruneHidden = (type: SourceType, presentIds: number[]) => {
const present = new Set(presentIds);
const gone = db
.selectDistinct({ itemId: torboxHiddenFiles.itemId })
.from(torboxHiddenFiles)
.where(eq(torboxHiddenFiles.source, type))
.all()
.map((row) => row.itemId)
.filter((id) => !present.has(id));
for (let i = 0; i < gone.length; i += PRUNE_BATCH)
db.delete(torboxHiddenFiles)
.where(
and(
eq(torboxHiddenFiles.source, type),
inArray(torboxHiddenFiles.itemId, gone.slice(i, i + PRUNE_BATCH))
)
)
.run();
};
+25 -1
View File
@@ -21,7 +21,7 @@ import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import type { ReadableStream as NodeReadableStream } from "node:stream/web";
import { and, asc, eq, gt, like, lte, sql } from "drizzle-orm";
import { and, asc, eq, gt, inArray, like, lte, sql } from "drizzle-orm";
import {
IMAGE_CACHE_LOCAL_PENDING_MAX_BYTES,
IMAGE_CACHE_MAX_FILE_BYTES,
@@ -178,6 +178,30 @@ export const removeCachedImages = async (type: SourceType, id: string) => {
.all();
if (!removed.length) return;
log.info({ type, id, removed: removed.length }, "已删除资源的图片缓存");
await removeStored(removed);
};
/** SQLite 单条语句的变量数有上限,按批删除索引 */
const REMOVE_BATCH = 500;
/** 清掉指定文件的缓存图片(文件被标记删除时用),key 见 imageCacheKey */
export const removeCachedFiles = async (keys: string[]) => {
const removed: { key: string; location: Location }[] = [];
for (let i = 0; i < keys.length; i += REMOVE_BATCH)
removed.push(
...db
.delete(imageCache)
.where(inArray(imageCache.key, keys.slice(i, i + REMOVE_BATCH)))
.returning({ key: imageCache.key, location: imageCache.location })
.all()
);
if (!removed.length) return;
log.info({ removed: removed.length }, "已删除文件的图片缓存");
await removeStored(removed);
};
/** 索引已删除后,清掉对应的本地文件与桶内对象 */
const removeStored = async (removed: { key: string; location: Location }[]) => {
const keysAt = (location: Location) =>
removed.filter((row) => row.location === location).map((row) => row.key);
await removeFiles(keysAt("local"));
+14 -1
View File
@@ -115,6 +115,17 @@ const SHOWCASE: MockItem[] = [
name: "Animated.Feature/README.txt",
size: 2 * 1024,
mimetype: "text/plain"
},
// 磁力里夹带的广告文件,用来验证「仅选疑似广告」与标记删除
{
name: "Animated.Feature/更多资源请访问.html",
size: 14 * 1024,
mimetype: "text/html"
},
{
name: "Animated.Feature/最新福利.chm",
size: 380 * 1024,
mimetype: "application/vnd.ms-htmlhelp"
}
]
},
@@ -253,7 +264,9 @@ const toLibraryItem = (mock: MockItem, index: number, now: number) => {
short_name: file.name.split("/").pop(),
size: Math.round(file.size),
mimetype: file.mimetype,
playUrl: `/api/stream/${mock.source}/${id}/${fileIndex + 1}`
playUrl: `/api/stream/${mock.source}/${id}/${fileIndex + 1}`,
// 由 getLibrary 统一按隐藏记录标注
hidden: false
}));
const item: LibraryItem = {
id,
+41 -5
View File
@@ -5,6 +5,12 @@ import { getMockLibrary } from "@/server/mock-library";
import { and, eq, gt, inArray, like, lte } from "drizzle-orm";
import { db } from "@/server/db";
import { torboxDeletedItems, torboxDownloadUrls } from "@/server/db/schema";
import {
forgetItemHidden,
getHiddenKeys,
hiddenKey,
pruneHidden
} from "@/server/hidden-files";
import { emitTorboxEvent } from "@/server/torbox-events";
import {
getRateLimitHeaders,
@@ -258,11 +264,30 @@ const getDeletedKeys = (
);
};
export const getLibrary = async ({
bypassCache = false
}: GetLibraryOptions = {}): Promise<LibraryResponse> => {
if (isMockMode()) return getMockLibrary();
/** 给每个文件标上是否已被标记删除 */
const markHidden = (response: LibraryResponse): LibraryResponse => {
const hidden = getHiddenKeys();
if (hidden.size === 0) return response;
return {
...response,
items: response.items.map((item) => ({
...item,
files: item.files.map((file) => ({
...file,
hidden: hidden.has(hiddenKey(item.source, item.id, file.id))
}))
}))
};
};
export const getLibrary = async (
options: GetLibraryOptions = {}
): Promise<LibraryResponse> =>
markHidden(isMockMode() ? getMockLibrary() : await fetchLibrary(options));
const fetchLibrary = async ({
bypassCache = false
}: GetLibraryOptions): Promise<LibraryResponse> => {
const errors: LibraryResponse["errors"] = {};
const items: LibraryItem[] = [];
let retryAfterMs: number | undefined;
@@ -276,6 +301,14 @@ export const getLibrary = async ({
);
const deleted = getDeletedKeys(results, bypassCache);
// 完整拉到的最新列表里已没有的资源,不必再留隐藏记录
if (bypassCache)
for (const { type, items: data, error } of results)
if (!error)
pruneHidden(
type,
data.map((item) => item.id)
);
// 按 SOURCES 的固定顺序合并,来源内保持接口返回的顺序,不受请求完成先后影响
for (const { type, items: data, error, ...result } of results) {
@@ -305,7 +338,9 @@ export const getLibrary = async ({
short_name: file.short_name,
size: file.size,
mimetype: file.mimetype ?? null,
playUrl: `/api/stream/${type}/${item.id}/${file.id}`
playUrl: `/api/stream/${type}/${item.id}/${file.id}`,
// 由 getLibrary 统一按隐藏记录标注
hidden: false
}))
});
}
@@ -396,6 +431,7 @@ export const deleteItem = async (type: SourceType, id: string) => {
db.delete(torboxDownloadUrls)
.where(like(torboxDownloadUrls.key, `${type}/${id}/%`))
.run();
forgetItemHidden(type, Number(id));
};
const MOCK_PLAY_ERROR =
+25 -4
View File
@@ -1,7 +1,12 @@
// 资源库 API 请求
import BaseClientService from "@/lib/axios";
import type { ApiResponse } from "@/types/api";
import type { LibraryResponse, SourceType } from "@/types/torbox";
import type {
DeleteItemsResult,
LibraryFileRef,
LibraryItemRef,
LibraryResponse
} from "@/types/torbox";
type GetLibraryOptions = {
/** 绕过 TorBox 列表缓存拿最新数据,仅用于用户手动刷新 */
@@ -16,7 +21,23 @@ export const getLibrary = async ({ fresh = false }: GetLibraryOptions = {}) => {
return data.data;
};
/** 从 TorBox 永久删除一个资源,服务端同时清掉它的图片缓存 */
export const deleteLibraryItem = async (source: SourceType, id: number) => {
await BaseClientService.delete<ApiResponse>(`/library/${source}/${id}`);
/** 批量从 TorBox 永久删除资源,服务端同时清掉图片缓存;部分失败时在结果里列出 */
export const deleteLibraryItems = async (items: LibraryItemRef[]) => {
const { data } = await BaseClientService.post<ApiResponse<DeleteItemsResult>>(
"/library/delete",
{ items }
);
return data.data;
};
/** 标记删除:只在本地隐藏文件,服务端同时清掉它们的图片缓存 */
export const hideLibraryFiles = async (files: LibraryFileRef[]) => {
await BaseClientService.post<ApiResponse>("/library/hidden", { files });
};
/** 恢复已标记删除的文件 */
export const restoreLibraryFiles = async (files: LibraryFileRef[]) => {
await BaseClientService.delete<ApiResponse>("/library/hidden", {
data: { files }
});
};
+13 -4
View File
@@ -11,38 +11,46 @@ export type SourceFilter = "all" | SourceType;
export type KindFilter = "all" | MediaKind;
/** airlocked 与下载状态相互独立:已加入 AirLock 的资源同时仍属于其下载状态 */
export type StatusFilter = "all" | DownloadStatus | "airlocked";
/** 显示范围:正常列表,或被标记删除的文件(回收站) */
export type ScopeFilter = "normal" | "trash";
type LibraryFilterState = {
keyword: string;
source: SourceFilter;
kind: KindFilter;
status: StatusFilter;
scope: ScopeFilter;
sort: LibrarySort;
order: SortOrder;
setKeyword: (keyword: string) => void;
setSource: (source: SourceFilter) => void;
setKind: (kind: KindFilter) => void;
setStatus: (status: StatusFilter) => void;
setScope: (scope: ScopeFilter) => void;
/** 切换排序方式,方向回到该方式的默认方向 */
setSort: (sort: LibrarySort) => void;
setOrder: (order: SortOrder) => void;
/** 清除筛选条件,排序方式保持不变 */
reset: () => void;
/** 只清除来源、下载状态、类型三个筛选项,保留关键词 */
/** 只清除来源、下载状态、类型、显示范围四个筛选项,保留关键词 */
resetFacets: () => void;
};
const initialFacets = {
source: "all",
kind: "all",
status: "all"
status: "all",
scope: "normal"
} as const;
const initialFilter = { keyword: "", ...initialFacets } as const;
/** 来源、下载状态、类型中任一项不是「全部」 */
/** 来源、下载状态、类型中任一项不是「全部」,或在回收站 */
export const selectFacetFiltered = (state: LibraryFilterState) =>
state.source !== "all" || state.kind !== "all" || state.status !== "all";
state.source !== "all" ||
state.kind !== "all" ||
state.status !== "all" ||
state.scope !== "normal";
/** 文件列表页的筛选条件(工具栏与列表共享),不持久化 */
export const useLibraryFilterStore = create<LibraryFilterState>()((set) => ({
@@ -53,6 +61,7 @@ export const useLibraryFilterStore = create<LibraryFilterState>()((set) => ({
setSource: (source) => set({ source }),
setKind: (kind) => set({ kind }),
setStatus: (status) => set({ status }),
setScope: (scope) => set({ scope }),
setSort: (sort) =>
set({
sort,
+87
View File
@@ -0,0 +1,87 @@
// 资源列表选择模式 store(批量标记删除 / 恢复 / 永久删除)
import { create } from "zustand";
import type { LibraryFileRef, LibraryItemRef } from "@/types/torbox";
import { getFileRefKey, getItemRefKey } from "@/utils/selection";
type SelectionState = {
/** 选择模式:卡片点击改为勾选,资源标题行出现勾选框 */
active: boolean;
/** 已选文件(用于标记删除 / 恢复),key 见 getFileRefKey */
files: Record<string, LibraryFileRef>;
/** 已选资源(用于永久删除),与已选文件相互独立 */
items: Record<string, LibraryItemRef>;
/** 最近一次点选的文件 key,Shift 连选的起点 */
anchor: string | null;
/** 退出选择模式时同时清空已选 */
setActive: (active: boolean) => void;
toggleFile: (ref: LibraryFileRef) => void;
/** 选中一批文件;传 anchor 时把它记为 Shift 连选的新起点 */
selectFiles: (refs: LibraryFileRef[], anchor?: LibraryFileRef) => void;
deselectFiles: (refs: LibraryFileRef[]) => void;
/** 反选:refs 中已选的取消、未选的选中 */
invertFiles: (refs: LibraryFileRef[]) => void;
toggleItem: (ref: LibraryItemRef) => void;
/** 操作完成后移除已处理的文件与资源(资源被删除时其下的文件一并移除) */
remove: (files: LibraryFileRef[], items: LibraryItemRef[]) => void;
/** 清空已选,保持选择模式 */
clear: () => void;
};
const empty = { files: {}, items: {}, anchor: null } as const;
export const useLibrarySelectionStore = create<SelectionState>()((set) => ({
active: false,
...empty,
setActive: (active) => set(active ? { active } : { active, ...empty }),
toggleFile: (ref) =>
set((state) => {
const key = getFileRefKey(ref);
const files = { ...state.files };
if (key in files) delete files[key];
else files[key] = ref;
return { files, anchor: key };
}),
selectFiles: (refs, anchor) =>
set((state) => {
const files = { ...state.files };
for (const ref of refs) files[getFileRefKey(ref)] = ref;
return { files, anchor: anchor ? getFileRefKey(anchor) : state.anchor };
}),
deselectFiles: (refs) =>
set((state) => {
const files = { ...state.files };
for (const ref of refs) delete files[getFileRefKey(ref)];
return { files };
}),
invertFiles: (refs) =>
set((state) => {
const files = { ...state.files };
for (const ref of refs) {
const key = getFileRefKey(ref);
if (key in files) delete files[key];
else files[key] = ref;
}
return { files };
}),
toggleItem: (ref) =>
set((state) => {
const key = getItemRefKey(ref);
const items = { ...state.items };
if (key in items) delete items[key];
else items[key] = ref;
return { items };
}),
remove: (removedFiles, removedItems) =>
set((state) => {
const gone = new Set(removedItems.map(getItemRefKey));
const files = { ...state.files };
for (const ref of removedFiles) delete files[getFileRefKey(ref)];
for (const [key, ref] of Object.entries(files))
if (gone.has(getItemRefKey(ref))) delete files[key];
const items = { ...state.items };
for (const key of gone) delete items[key];
return { files, items };
}),
clear: () => set(empty)
}));
+14
View File
@@ -12,6 +12,20 @@ export type LibraryFile = {
mimetype: string | null;
/** 服务端生成的相对播放路径,不含 token */
playUrl: string;
/** 已被标记删除(只在本地隐藏,TorBox 上仍在),回收站可恢复 */
hidden: boolean;
};
/** 资源的引用:来源 + 资源 id */
export type LibraryItemRef = { source: SourceType; id: number };
/** 文件的引用:id 是所属资源的 id,文件 id 只在资源内唯一 */
export type LibraryFileRef = LibraryItemRef & { fileId: number };
/** 批量永久删除的结果:失败的项带原因,成功的已从 TorBox 删除 */
export type DeleteItemsResult = {
deleted: LibraryItemRef[];
failed: (LibraryItemRef & { message: string })[];
};
/**
+4
View File
@@ -1,4 +1,5 @@
// 媒体文件类型与路径处理函数
import { AD_FILE_EXT } from "@/constants/library";
import { IMAGE_EXT, VIDEO_EXT } from "@/constants/torbox";
import type { SourceType } from "@/types/torbox";
@@ -32,6 +33,9 @@ export const getMediaKind = (
return "other";
};
/** 扩展名是否属于常见的广告文件(见 AD_FILE_EXT) */
export const isAdExt = (ext: string) => AD_FILE_EXT.has(ext);
/** 拼接带文件名后缀和 token 的完整流地址;文件名仅供播放器显示 */
export const buildStreamUrl = (
origin: string,
+31
View File
@@ -0,0 +1,31 @@
// 资源列表选择模式的 key 与范围计算
import type { LibraryFileRef, LibraryItemRef } from "@/types/torbox";
/** 资源的选择 key,与列表的资源分组 key 一致 */
export const getItemRefKey = ({ source, id }: LibraryItemRef) =>
`${source}-${id}`;
/** 文件的选择 key:文件 id 只在资源内唯一,所以带上资源 */
export const getFileRefKey = ({ source, id, fileId }: LibraryFileRef) =>
`${source}-${id}:${fileId}`;
/**
* Shift 连选:返回锚点到目标之间(含两端)的文件,顺序即列表中的显示顺序。
* 没有锚点、或锚点不在当前显示的文件里(被折叠、被筛掉)时,只返回目标本身
*/
export const getRangeRefs = (
ordered: LibraryFileRef[],
anchorKey: string | null,
target: LibraryFileRef
) => {
const targetKey = getFileRefKey(target);
let from = -1;
let to = -1;
ordered.forEach((ref, index) => {
const key = getFileRefKey(ref);
if (key === anchorKey) from = index;
if (key === targetKey) to = index;
});
if (from < 0 || to < 0) return [target];
return ordered.slice(Math.min(from, to), Math.max(from, to) + 1);
};