@@ -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": []
|
||||
}
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }
|
||||
@@ -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
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
@@ -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 })[];
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
Reference in New Issue
Block a user