feat(web): W5-B file manager page /console/files
Roots + lazy dir tree, virtualized grid with status badges and asset/slot/ download links, breadcrumb, sort, glob search, hidden toggle, filter chips, paging and truncation notice; multi-select (Shift/Ctrl/all), action bar, context menu, drag-to-move with dryRun preview, keyboard navigation, offline (503) and empty/error states, mobile layout with tree drawer. Rename/move/copy/delete dialogs with dryRun previews and reference checks, 4-step organize wizard, trash view (restore/restore-to/purge, expiry). DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2e2d56184c
commit
89ea68465e
17 files changed
+6269
-2
No files matched your search
@@ -0,0 +1,274 @@
|
||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileRef,
|
||||
FileRootList,
|
||||
FileStat,
|
||||
type FilesDeleteRequest,
|
||||
FilesDeleteResult,
|
||||
type FilesIdentifyRequest,
|
||||
FilesIdentifyResult,
|
||||
FilesListResponse,
|
||||
type FilesMkdirRequest,
|
||||
FilesMkdirResult,
|
||||
type FilesMoveRequest,
|
||||
type FilesOrganizeRequest,
|
||||
FilesOrganizeResult,
|
||||
type FilesProbeRequest,
|
||||
FilesProbeResult,
|
||||
type FilesRenameRequest,
|
||||
FilesRenameResult,
|
||||
type FilesScrapeRequest,
|
||||
FilesScrapeResult,
|
||||
FilesTransferResult,
|
||||
FilesTrashList,
|
||||
type FilesTrashPurgeRequest,
|
||||
FilesTrashPurgeResult,
|
||||
type FilesTrashRestoreRequest,
|
||||
FilesTrashRestoreResult,
|
||||
} from "@umt/contracts";
|
||||
import type { z } from "zod";
|
||||
import type { FetcherArg } from "~/lib/api.ts";
|
||||
import { ApiRequestError, apiParsed } from "./parse.ts";
|
||||
|
||||
// 文件管理(W5-A:/files*;规格 docs/plan/06-file-manager-spec.md §2,形状取 contracts/files.ts)。
|
||||
// ?mock=1 只在开发构建生效:不连后端,用 components/files/mock.ts 的内存文件树演示交互。
|
||||
|
||||
export type {
|
||||
FileDeleteCheck,
|
||||
FileEntry,
|
||||
FileIdentifyCandidate,
|
||||
FileIdentifyItem,
|
||||
FileRef,
|
||||
FileRoot,
|
||||
FileStatus,
|
||||
FilesOrganizeGroup,
|
||||
FilesTransferResult,
|
||||
TrashItem,
|
||||
} from "@umt/contracts";
|
||||
|
||||
// ─── mock 开关 ───────────────────────────────────────────────────
|
||||
|
||||
const MOCK_KEY = "umt-files-mock";
|
||||
|
||||
/** 纯函数:开发构建才允许;URL ?mock=1 打开并记在会话里,?mock=0 关闭 */
|
||||
export function resolveFilesMock(o: {
|
||||
dev: boolean;
|
||||
search: string;
|
||||
storage?: Pick<Storage, "getItem" | "setItem" | "removeItem"> | null;
|
||||
}) {
|
||||
if (!o.dev) return false;
|
||||
const p = new URLSearchParams(o.search);
|
||||
try {
|
||||
if (!o.storage) return p.get("mock") === "1";
|
||||
if (p.get("mock") === "1") o.storage.setItem(MOCK_KEY, "1");
|
||||
if (p.get("mock") === "0") o.storage.removeItem(MOCK_KEY);
|
||||
return o.storage.getItem(MOCK_KEY) === "1";
|
||||
} catch {
|
||||
return p.get("mock") === "1";
|
||||
}
|
||||
}
|
||||
|
||||
export function filesMockEnabled(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
let storage: Storage | null = null;
|
||||
try {
|
||||
storage = window.sessionStorage;
|
||||
} catch {
|
||||
storage = null;
|
||||
}
|
||||
return resolveFilesMock({ dev: import.meta.env.DEV, search: window.location.search, storage });
|
||||
}
|
||||
|
||||
let mockModule: Promise<typeof import("~/components/files/mock.ts")> | null = null;
|
||||
const loadMock = () => {
|
||||
mockModule ??= import("~/components/files/mock.ts");
|
||||
return mockModule;
|
||||
};
|
||||
|
||||
/** 统一出口:mock 时走内存实现,否则走后端并按合同校验;失败抛 ApiRequestError */
|
||||
async function call<S extends z.ZodType>(schema: S, path: string, arg: FetcherArg = {}): Promise<z.infer<S>> {
|
||||
if (filesMockEnabled()) {
|
||||
const m = await loadMock();
|
||||
return (await m.mockFiles(path, arg)) as z.infer<S>;
|
||||
}
|
||||
return apiParsed(schema, `/api/v1${path}`, arg);
|
||||
}
|
||||
const post = <S extends z.ZodType>(schema: S, path: string, body: unknown) =>
|
||||
call(schema, path, { method: "POST", body });
|
||||
|
||||
// ─── 错误 ────────────────────────────────────────────────────────
|
||||
|
||||
/** 文件专有原因放在 message 前缀(如 "root_unavailable: …");取出来分流文案 */
|
||||
export function filesErrorReason(err: unknown): string | null {
|
||||
if (!(err instanceof ApiRequestError)) return null;
|
||||
const m = /^([a-z_]+):/.exec(err.message);
|
||||
return m ? (m[1] as string) : null;
|
||||
}
|
||||
|
||||
/** 根离线 → 显式离线态(不当作空目录) */
|
||||
export const isRootOffline = (err: unknown) =>
|
||||
filesErrorReason(err) === "root_unavailable" ||
|
||||
(err instanceof ApiRequestError && err.code === "upstream_unavailable");
|
||||
|
||||
/** 去掉原因前缀,给人看的部分 */
|
||||
export function filesErrorText(err: unknown, fallback = "操作失败"): string {
|
||||
if (!(err instanceof Error) || !err.message) return fallback;
|
||||
return err.message.replace(/^[a-z_]+:\s*/, "");
|
||||
}
|
||||
|
||||
// ─── 查询 ────────────────────────────────────────────────────────
|
||||
|
||||
export const filesKeys = {
|
||||
all: ["files"] as const,
|
||||
roots: ["files", "roots"] as const,
|
||||
list: (q: ListQuery) => ["files", "list", q.root, q.path, q] as const,
|
||||
tree: (root: string, path: string) => ["files", "tree", root, path] as const,
|
||||
stat: (root: string, path: string) => ["files", "stat", root, path] as const,
|
||||
trash: ["files", "trash"] as const,
|
||||
};
|
||||
|
||||
export function useFileRoots() {
|
||||
return useQuery({
|
||||
queryKey: filesKeys.roots,
|
||||
queryFn: ({ signal }) => call(FileRootList, "/files/roots", { signal }).then((r) => r.items),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export type ListQuery = {
|
||||
root: string;
|
||||
path: string;
|
||||
sort?: "name" | "mtime" | "size" | "status";
|
||||
order?: "asc" | "desc";
|
||||
q?: string;
|
||||
filter?: string;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
export const PAGE_SIZE = 200;
|
||||
|
||||
export function useFileList(q: ListQuery | null) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: filesKeys.list(q ?? { root: "", path: "" }),
|
||||
enabled: !!q?.root,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
placeholderData: (prev, prevQuery) =>
|
||||
// 同一目录内换排序/过滤保留旧数据防闪;换目录不保留(避免把上一个目录的内容当成当前目录)
|
||||
prevQuery && prevQuery.queryKey[2] === q?.root && prevQuery.queryKey[3] === q?.path
|
||||
? keepPreviousData(prev)
|
||||
: undefined,
|
||||
retry: (n, err) => !(err instanceof ApiRequestError) && n < 2,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
call(FilesListResponse, "/files", {
|
||||
params: {
|
||||
root: q?.root,
|
||||
path: q?.path ?? "",
|
||||
sort: q?.sort,
|
||||
order: q?.order,
|
||||
q: q?.q || undefined,
|
||||
filter: q?.filter || undefined,
|
||||
hidden: q?.hidden ? "true" : undefined,
|
||||
cursor: pageParam,
|
||||
limit: PAGE_SIZE,
|
||||
},
|
||||
signal,
|
||||
}),
|
||||
getNextPageParam: (last: FilesListResponse) => last.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 目录树懒加载:只取子目录(按名称),取前 1000 项 */
|
||||
export function useDirChildren(root: string, path: string, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: filesKeys.tree(root, path),
|
||||
enabled: enabled && !!root,
|
||||
staleTime: 30_000,
|
||||
retry: false,
|
||||
queryFn: ({ signal }) =>
|
||||
call(FilesListResponse, "/files", {
|
||||
params: { root, path, sort: "name", order: "asc", limit: 1000 },
|
||||
signal,
|
||||
}).then((r) => ({ dirs: r.entries.filter((e) => e.type === "dir"), truncated: r.truncated || !!r.nextCursor })),
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileStat(root: string | null, path: string | null) {
|
||||
return useQuery({
|
||||
queryKey: filesKeys.stat(root ?? "", path ?? ""),
|
||||
enabled: !!root && path != null,
|
||||
retry: false,
|
||||
queryFn: ({ signal }) => call(FileStat, "/files/stat", { params: { root, path }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useTrash(enabled = true) {
|
||||
return useInfiniteQuery({
|
||||
queryKey: filesKeys.trash,
|
||||
enabled,
|
||||
initialPageParam: undefined as string | undefined,
|
||||
queryFn: ({ pageParam, signal }) =>
|
||||
call(FilesTrashList, "/files/trash", { params: { cursor: pageParam, limit: 100 }, signal }),
|
||||
getNextPageParam: (last: FilesTrashList) => last.nextCursor ?? undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/** 非媒体小文件(NFO/字幕/图片 ≤ 20 MB)下载与预览地址 */
|
||||
export function fileDownloadUrl(root: string, path: string): string {
|
||||
const qs = new URLSearchParams({ root, path });
|
||||
return `/api/v1/files/download?${qs}`;
|
||||
}
|
||||
|
||||
export function useTextPreview(root: string | null, path: string | null, enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["files", "preview", root, path],
|
||||
enabled: enabled && !!root && !!path,
|
||||
retry: false,
|
||||
queryFn: async ({ signal }) => {
|
||||
if (filesMockEnabled()) return (await loadMock()).mockPreview(root ?? "", path ?? "");
|
||||
const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal });
|
||||
if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`);
|
||||
const text = await res.text();
|
||||
return text.length > 64_000 ? `${text.slice(0, 64_000)}\n…(已截断)` : text;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── 变更 ────────────────────────────────────────────────────────
|
||||
|
||||
function useFilesMutation<B, T>(fn: (b: B) => Promise<T>, opts: { invalidate?: (b: B) => boolean } = {}) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: fn,
|
||||
onSuccess: (_d, b) => {
|
||||
if (opts.invalidate?.(b) ?? true) void qc.invalidateQueries({ queryKey: filesKeys.all });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const notDry = (b: { dryRun?: boolean }) => b.dryRun === false;
|
||||
|
||||
export const useMkdir = () => useFilesMutation((b: FilesMkdirRequest) => post(FilesMkdirResult, "/files/mkdir", b));
|
||||
export const useRename = () =>
|
||||
useFilesMutation((b: FilesRenameRequest) => post(FilesRenameResult, "/files/rename", b), { invalidate: notDry });
|
||||
export const useMove = () =>
|
||||
useFilesMutation((b: FilesMoveRequest) => post(FilesTransferResult, "/files/move", b), { invalidate: notDry });
|
||||
export const useDelete = () =>
|
||||
useFilesMutation((b: FilesDeleteRequest) => post(FilesDeleteResult, "/files/delete", b), { invalidate: notDry });
|
||||
export const useRestore = () =>
|
||||
useFilesMutation((b: FilesTrashRestoreRequest) => post(FilesTrashRestoreResult, "/files/trash/restore", b));
|
||||
export const usePurge = () =>
|
||||
useFilesMutation((b: FilesTrashPurgeRequest) => post(FilesTrashPurgeResult, "/files/trash/purge", b));
|
||||
export const useIdentify = () =>
|
||||
useMutation({ mutationFn: (b: FilesIdentifyRequest) => post(FilesIdentifyResult, "/files/identify", b) });
|
||||
export const useOrganizeFiles = () =>
|
||||
useFilesMutation((b: FilesOrganizeRequest) => post(FilesOrganizeResult, "/files/organize", b), {
|
||||
invalidate: notDry,
|
||||
});
|
||||
export const useScrape = () =>
|
||||
useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) });
|
||||
export const useProbe = () => useFilesMutation((b: FilesProbeRequest) => post(FilesProbeResult, "/files/probe", b));
|
||||
|
||||
/** 行的唯一键:根 + 相对路径 */
|
||||
export const refKey = (r: FileRef) => `${r.root}\u0000${r.path}`;
|
||||
export const entryRef = (root: string, e: Pick<FileEntry, "path">): FileRef => ({ root, path: e.path });
|
||||
@@ -3,8 +3,8 @@ import { consolePath, findNavItem, NAV_ITEMS } from "./nav.ts";
|
||||
|
||||
describe("控制台导航", () => {
|
||||
it("12 个工作区 + 引擎面板 + 审计,路径唯一且都在 /console 下", () => {
|
||||
expect(NAV_ITEMS).toHaveLength(14);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(14);
|
||||
expect(NAV_ITEMS).toHaveLength(15);
|
||||
expect(new Set(NAV_ITEMS.map((i) => i.to)).size).toBe(15);
|
||||
expect(NAV_ITEMS.every((i) => i.to.startsWith("/console"))).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Download,
|
||||
FileStack,
|
||||
Fingerprint,
|
||||
FolderTree,
|
||||
Globe,
|
||||
House,
|
||||
Inbox,
|
||||
@@ -63,6 +64,12 @@ export const NAV_GROUPS: NavGroup[] = [
|
||||
icon: FileStack,
|
||||
description: "Copy/Move 入库前后路径与逐文件结果",
|
||||
},
|
||||
{
|
||||
to: "/console/files",
|
||||
label: "文件",
|
||||
icon: FolderTree,
|
||||
description: "浏览已登记的库根与下载目录,改名、移动、删除与批量整理",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export type MenuItem =
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
/** 快捷键提示 */
|
||||
hint?: string;
|
||||
/** 禁用原因(显示为 title,并在行内小字说明) */
|
||||
disabled?: string | null;
|
||||
destructive?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| { key: string; separator: true };
|
||||
|
||||
export type MenuAnchor = { x: number; y: number };
|
||||
|
||||
/**
|
||||
* 右键菜单 / 行 ⋯ 菜单共用的浮层(pg-float)。按坐标定位,越界自动翻转;
|
||||
* 点外面、Esc、滚动、失焦都会关闭;↑↓ 在可用项间移动,Enter 执行。
|
||||
*/
|
||||
export function ContextMenu({
|
||||
anchor,
|
||||
items,
|
||||
onClose,
|
||||
label = "操作",
|
||||
}: {
|
||||
anchor: MenuAnchor | null;
|
||||
items: MenuItem[];
|
||||
onClose: () => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [active, setActive] = useState(-1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return;
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const left = anchor.x + r.width > vw - 8 ? Math.max(8, anchor.x - r.width) : anchor.x;
|
||||
const top = anchor.y + r.height > vh - 8 ? Math.max(8, anchor.y - r.height) : anchor.y;
|
||||
setPos({ left, top });
|
||||
ref.current.focus();
|
||||
}, [anchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const close = (e: Event) => {
|
||||
if (e.type === "mousedown" && ref.current?.contains(e.target as Node)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("mousedown", close);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("blur", close);
|
||||
document.addEventListener("scroll", close, true);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", close);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("blur", close);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
};
|
||||
}, [anchor, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) {
|
||||
setPos(null);
|
||||
setActive(-1);
|
||||
}
|
||||
}, [anchor]);
|
||||
|
||||
if (!anchor || typeof document === "undefined") return null;
|
||||
const actionable = items
|
||||
.map((it, i) => ({ it, i }))
|
||||
.filter(({ it }) => !("separator" in it) && !it.disabled)
|
||||
.map(({ i }) => i);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!actionable.length) return;
|
||||
const cur = actionable.indexOf(active);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? actionable[(cur + 1) % actionable.length]
|
||||
: actionable[(cur - 1 + actionable.length) % actionable.length];
|
||||
setActive(next ?? -1);
|
||||
} else if (e.key === "Enter" && active >= 0) {
|
||||
e.preventDefault();
|
||||
const it = items[active];
|
||||
if (it && !("separator" in it) && !it.disabled) {
|
||||
onClose();
|
||||
it.onSelect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ left: pos?.left ?? anchor.x, top: pos?.top ?? anchor.y, visibility: pos ? "visible" : "hidden" }}
|
||||
className="pg-float fixed z-50 min-w-52 max-w-72 animate-pg-pop rounded-2xl p-1.5 outline-none"
|
||||
>
|
||||
{items.map((it, i) =>
|
||||
"separator" in it ? (
|
||||
<hr key={it.key} className="my-1 border-0 border-t border-border" />
|
||||
) : (
|
||||
<button
|
||||
key={it.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-disabled={!!it.disabled}
|
||||
title={it.disabled ?? undefined}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => {
|
||||
if (it.disabled) return;
|
||||
onClose();
|
||||
it.onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-body transition-colors duration-micro",
|
||||
it.disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: it.destructive
|
||||
? "text-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
: "text-foreground hover:bg-foreground/[0.06]",
|
||||
active === i &&
|
||||
!it.disabled &&
|
||||
(it.destructive ? "bg-destructive/10 text-destructive" : "bg-foreground/[0.06]"),
|
||||
)}
|
||||
>
|
||||
{it.icon ? <it.icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> : <span className="w-4" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{it.label}</span>
|
||||
{it.disabled ? <span className="block text-meta text-muted-foreground">{it.disabled}</span> : null}
|
||||
</span>
|
||||
{it.hint ? <kbd className="mt-0.5 font-mono text-meta text-muted-foreground">{it.hint}</kbd> : null}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,935 @@
|
||||
import {
|
||||
ArrowRight,
|
||||
Copy,
|
||||
FolderInput,
|
||||
FolderPlus,
|
||||
LoaderCircle,
|
||||
PenLine,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileRef,
|
||||
type FileRoot,
|
||||
type FilesTransferResult,
|
||||
filesErrorReason,
|
||||
filesErrorText,
|
||||
useDelete,
|
||||
useFileStat,
|
||||
useIdentify,
|
||||
useMkdir,
|
||||
useMove,
|
||||
useRename,
|
||||
useScrape,
|
||||
} from "~/api/files.ts";
|
||||
import { FormField } from "~/components/console/form-field.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Switch } from "~/components/ui/switch.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { DirTree } from "./dir-tree.tsx";
|
||||
import {
|
||||
baseName,
|
||||
deleteBlockText,
|
||||
nameProblem,
|
||||
newIdempotencyKey,
|
||||
splitCommon,
|
||||
stemRange,
|
||||
suggestName,
|
||||
} from "./model.ts";
|
||||
|
||||
// ─── 共用 ───────────────────────────────────────────────────────
|
||||
|
||||
/** 前后路径对照:公共前缀淡显,差异部分高亮 */
|
||||
export function PathPair({ from, to, tone }: { from: string; to: string | null; tone?: "conflict" | "skip" }) {
|
||||
if (!to) return <p className="font-mono text-meta break-all text-muted-foreground">{from}</p>;
|
||||
const s = splitCommon(from, to);
|
||||
return (
|
||||
<div className="min-w-0 font-mono text-meta leading-relaxed">
|
||||
<p className="break-all text-muted-foreground">
|
||||
<span className="opacity-60">{s.common}</span>
|
||||
{s.a}
|
||||
</p>
|
||||
<p className={cn("flex gap-1 break-all", tone === "conflict" ? "text-destructive" : "text-foreground")}>
|
||||
<ArrowRight aria-hidden className="mt-0.5 h-3 w-3 shrink-0" />
|
||||
<span>
|
||||
<span className="opacity-60">{s.common}</span>
|
||||
<mark className="rounded-tight bg-foreground/[0.08] px-0.5 text-inherit">{s.b}</mark>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Notice({ tone, children }: { tone: "warning" | "destructive" | "neutral"; children: React.ReactNode }) {
|
||||
return (
|
||||
<div
|
||||
role={tone === "neutral" ? undefined : "alert"}
|
||||
className={cn(
|
||||
"flex gap-2 rounded-xl border px-3 py-2 text-caption",
|
||||
tone === "destructive" && "border-destructive/35 bg-destructive/10 text-destructive",
|
||||
tone === "warning" && "border-warning/35 bg-warning/10 text-warning",
|
||||
tone === "neutral" && "border-border bg-foreground/[0.03] text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{tone !== "neutral" ? <TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" /> : null}
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useDebounced<T>(v: T, ms: number): T {
|
||||
const [d, setD] = useState(v);
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => setD(v), ms);
|
||||
return () => clearTimeout(t);
|
||||
}, [v, ms]);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ─── 新建目录 ───────────────────────────────────────────────────
|
||||
|
||||
export function MkdirDialog({
|
||||
root,
|
||||
path,
|
||||
open,
|
||||
onOpenChange,
|
||||
onCreated,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
path: string;
|
||||
open: boolean;
|
||||
onOpenChange: (o: boolean) => void;
|
||||
onCreated?: (path: string) => void;
|
||||
}) {
|
||||
const [name, setName] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const mkdir = useMkdir();
|
||||
const key = useMemo(() => (open ? newIdempotencyKey() : ""), [open]);
|
||||
const submit = (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
const p = nameProblem(name);
|
||||
if (p) return setErr(p);
|
||||
mkdir.mutate(
|
||||
{ idempotencyKey: key, root: root.id, path, name: name.trim() },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
toast.success(`已新建目录 ${name.trim()}`);
|
||||
setName("");
|
||||
onOpenChange(false);
|
||||
onCreated?.(r.path);
|
||||
},
|
||||
onError: (e) => setErr(filesErrorReason(e) === "exists" ? "同名项已存在" : filesErrorText(e)),
|
||||
},
|
||||
);
|
||||
};
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<form onSubmit={submit} className="grid gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建目录</DialogTitle>
|
||||
<DialogDescription className="font-mono break-all">
|
||||
{root.hostPath}
|
||||
{path ? `/${path}` : ""}/
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormField label="目录名" htmlFor="mkdir-name" error={err}>
|
||||
<Input
|
||||
id="mkdir-name"
|
||||
autoFocus
|
||||
value={name}
|
||||
aria-invalid={!!err}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setErr(null);
|
||||
}}
|
||||
/>
|
||||
</FormField>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={mkdir.isPending || !name.trim()}>
|
||||
{mkdir.isPending ? <LoaderCircle className="animate-spin" /> : <FolderPlus />}
|
||||
新建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 重命名 ─────────────────────────────────────────────────────
|
||||
|
||||
export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
|
||||
const isDir = entry.type === "dir";
|
||||
const [name, setName] = useState(entry.name);
|
||||
const [withSidecars, setWithSidecars] = useState(true);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const key = useMemo(() => newIdempotencyKey(), []);
|
||||
const preview = useRename();
|
||||
const exec = useRename();
|
||||
const identify = useIdentify();
|
||||
const debounced = useDebounced(name.trim(), 350);
|
||||
const problem = nameProblem(name);
|
||||
const changed = name.trim() !== entry.name;
|
||||
|
||||
useEffect(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const [a, b] = stemRange(entry.name, isDir);
|
||||
el.setSelectionRange(a, b);
|
||||
}, [entry.name, isDir]);
|
||||
|
||||
// 输入停顿后 dryRun:列出将一起改名的 sidecars 与资产/绑定同步
|
||||
const previewMutate = preview.mutate;
|
||||
useEffect(() => {
|
||||
if (!debounced || debounced === entry.name || nameProblem(debounced)) return;
|
||||
previewMutate({
|
||||
idempotencyKey: `${key}-preview`,
|
||||
root: root.id,
|
||||
path: entry.path,
|
||||
newName: debounced,
|
||||
withSidecars,
|
||||
dryRun: true,
|
||||
});
|
||||
}, [debounced, withSidecars, entry.name, entry.path, key, root.id, previewMutate]);
|
||||
|
||||
const suggest = () =>
|
||||
identify.mutate(
|
||||
{ items: [{ root: root.id, path: entry.path }] },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const it = r.items[0];
|
||||
const c = it?.candidates[0];
|
||||
if (!it || !c) return toast.info("没有可信的识别结果,无法给出建议名");
|
||||
setName(suggestName(c, it.parsed, entry.name, isDir));
|
||||
setErr(null);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "识别失败")),
|
||||
},
|
||||
);
|
||||
|
||||
const submit = (ev: React.FormEvent) => {
|
||||
ev.preventDefault();
|
||||
if (problem) return setErr(problem);
|
||||
if (!changed) return onClose();
|
||||
exec.mutate(
|
||||
{ idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
toast.success(
|
||||
`已改名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
|
||||
r.recordId
|
||||
? {
|
||||
action: {
|
||||
label: "整理记录",
|
||||
onClick: () => window.location.assign(`/console/organize/${r.recordId}`),
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => {
|
||||
const reason = filesErrorReason(e);
|
||||
setErr(
|
||||
reason === "exists"
|
||||
? "目标名已存在,不会覆盖"
|
||||
: reason === "download_active"
|
||||
? "做种中,改名会让下载器找不到文件"
|
||||
: filesErrorText(e),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const pv = preview.data && preview.variables?.newName === debounced ? preview.data : null;
|
||||
const pvErr = preview.error && preview.variables?.newName === debounced ? preview.error : null;
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<form onSubmit={submit} className="grid min-w-0 gap-4">
|
||||
<DialogHeader>
|
||||
<DialogTitle>重命名</DialogTitle>
|
||||
<DialogDescription className="break-all">{entry.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<FormField label="新名称" htmlFor="rename-name" error={err ?? (problem && changed ? problem : null)}>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="rename-name"
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
aria-invalid={!!err}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
setErr(null);
|
||||
}}
|
||||
className="font-mono"
|
||||
/>
|
||||
{entry.fileKind === "video" || isDir ? (
|
||||
<Button type="button" variant="outline" onClick={suggest} disabled={identify.isPending}>
|
||||
{identify.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
|
||||
建议名
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</FormField>
|
||||
{!isDir ? (
|
||||
<div className="flex items-center justify-between gap-3 text-caption">
|
||||
<span>
|
||||
同名附属文件一起改
|
||||
<span className="block text-meta text-muted-foreground">同 stem 的字幕 / NFO / 图片</span>
|
||||
</span>
|
||||
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" />
|
||||
</div>
|
||||
) : null}
|
||||
{changed && !problem ? (
|
||||
<div className="space-y-2">
|
||||
{preview.isPending ? (
|
||||
<p className="flex items-center gap-2 text-meta text-muted-foreground">
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" /> 预览中
|
||||
</p>
|
||||
) : null}
|
||||
{pvErr ? <Notice tone="destructive">{filesErrorText(pvErr)}</Notice> : null}
|
||||
{pv ? (
|
||||
<>
|
||||
<ul className="max-h-48 space-y-1.5 overflow-auto">
|
||||
{pv.renames.map((r) => (
|
||||
<li key={r.from} className="rounded-lg border border-border/60 px-2.5 py-1.5">
|
||||
<div className="mb-0.5">
|
||||
<StatusBadge>{r.role === "main" ? "本体" : "附属"}</StatusBadge>
|
||||
</div>
|
||||
<PathPair from={r.from} to={r.to} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{pv.assetLocations > 0 ? (
|
||||
<Notice tone="neutral">
|
||||
将同步 {pv.assetLocations} 条资产位置,并写一条整理记录(action=rename)。
|
||||
</Notice>
|
||||
) : null}
|
||||
{pv.bindings.length > 0 ? (
|
||||
<Notice tone="warning">
|
||||
这是作品/季的绑定目录:将新建绑定并把旧绑定标记为已替代,之后新集入库进入新目录。
|
||||
</Notice>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={exec.isPending || !changed || !!problem}>
|
||||
{exec.isPending ? <LoaderCircle className="animate-spin" /> : <PenLine />}
|
||||
重命名
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 移动 / 复制到… ─────────────────────────────────────────────
|
||||
|
||||
export function TransferDialog({
|
||||
roots,
|
||||
items,
|
||||
initialMode,
|
||||
initialTarget,
|
||||
onClose,
|
||||
}: {
|
||||
roots: FileRoot[];
|
||||
items: FileRef[];
|
||||
initialMode: "move" | "copy";
|
||||
initialTarget?: FileRef | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [mode, setMode] = useState(initialMode);
|
||||
const [target, setTarget] = useState<FileRef | null>(initialTarget ?? null);
|
||||
const [plan, setPlan] = useState<FilesTransferResult | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const key = useMemo(() => newIdempotencyKey(), []);
|
||||
const dry = useMove();
|
||||
const exec = useMove();
|
||||
const rootById = new Map(roots.map((r) => [r.id, r]));
|
||||
const targetRoot = target ? rootById.get(target.root) : undefined;
|
||||
|
||||
const runPreview = (t: FileRef, m: "move" | "copy") => {
|
||||
setPlan(null);
|
||||
setErr(null);
|
||||
dry.mutate(
|
||||
{ idempotencyKey: `${key}-dry-${m}-${t.root}-${t.path}`, items, target: t, mode: m, dryRun: true },
|
||||
{ onSuccess: setPlan, onError: (e) => setErr(filesErrorText(e, "预览失败")) },
|
||||
);
|
||||
};
|
||||
|
||||
// 拖拽进来时目标已定:直接出预览
|
||||
const dryMutate = dry.mutate;
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开时跑一次
|
||||
useEffect(() => {
|
||||
if (initialTarget) runPreview(initialTarget, initialMode);
|
||||
}, [dryMutate]);
|
||||
|
||||
const conflicts = plan?.pairs.filter((p) => p.conflict) ?? [];
|
||||
const hnrBlocked = mode === "move" && (plan?.hnrBlocks.length ?? 0) > 0;
|
||||
const canRun = !!plan && !hnrBlocked && plan.spaceOk && conflicts.length < plan.pairs.length;
|
||||
|
||||
const run = () => {
|
||||
if (!plan || !target) return;
|
||||
exec.mutate(
|
||||
{ idempotencyKey: key, items, target, mode, dryRun: false, planId: plan.planId },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const ok = r.executed?.succeeded.length ?? 0;
|
||||
const failed = r.executed?.failed ?? [];
|
||||
const recordId = r.executed?.recordId;
|
||||
const msg = `${mode === "move" ? "已移动" : "已复制"} ${ok} 项${failed.length ? `,${failed.length} 项失败` : ""}`;
|
||||
(failed.length ? toast.warning : toast.success)(msg, {
|
||||
description: failed
|
||||
.slice(0, 3)
|
||||
.map((f) => `${baseName(f.path)}:${f.message}`)
|
||||
.join("\n"),
|
||||
action: recordId
|
||||
? { label: "整理记录", onClick: () => window.location.assign(`/console/organize/${recordId}`) }
|
||||
: undefined,
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => {
|
||||
const reason = filesErrorReason(e);
|
||||
if (reason === "plan_stale") {
|
||||
setErr("源文件在预览后发生了变化,已重新生成预览,请再确认一次。");
|
||||
runPreview(target, mode);
|
||||
} else setErr(filesErrorText(e));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} ·
|
||||
先预览前后路径、冲突与空间,确认后才执行; 统一走可恢复的整理计划,跨卷为 Copy → 核验 → 删源。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
|
||||
<div className="min-h-0 space-y-3">
|
||||
<div className="flex gap-1.5">
|
||||
{(["move", "copy"] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
type="button"
|
||||
aria-pressed={mode === m}
|
||||
onClick={() => {
|
||||
setMode(m);
|
||||
if (target) runPreview(target, m);
|
||||
}}
|
||||
className={cn(
|
||||
"flex-1 rounded-full border px-3 py-1.5 text-caption transition-colors duration-micro",
|
||||
mode === m
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{m === "move" ? "移动" : "复制"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="max-h-72 overflow-y-auto rounded-xl border border-border p-1.5 md:max-h-96">
|
||||
<DirTree
|
||||
roots={roots}
|
||||
current={target}
|
||||
pickOnly
|
||||
defaultOpen={roots.filter((r) => r.writable && r.health !== "offline").map((r) => r.id)}
|
||||
onSelect={(loc) => {
|
||||
setTarget(loc);
|
||||
runPreview(loc, mode);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-meta text-muted-foreground">只有可写的媒体库根能作为目标。</p>
|
||||
</div>
|
||||
<div className="min-w-0 space-y-3">
|
||||
{target && targetRoot ? (
|
||||
<div className="rounded-xl border border-border px-3 py-2">
|
||||
<p className="text-meta text-muted-foreground">目标目录</p>
|
||||
<p className="font-mono text-caption break-all">
|
||||
{targetRoot.hostPath}
|
||||
{target.path ? `/${target.path}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<Notice tone="neutral">在左侧选择目标目录。</Notice>
|
||||
)}
|
||||
{dry.isPending ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览…
|
||||
</p>
|
||||
) : null}
|
||||
{err ? <Notice tone="destructive">{err}</Notice> : null}
|
||||
{plan ? (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<StatusBadge>
|
||||
{plan.pairs.length} 项 · {formatBytes(plan.totalBytes, 1)}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={plan.spaceOk ? "success" : "destructive"}>
|
||||
{plan.spaceOk ? "空间足够" : "空间不足"}
|
||||
{plan.freeBytes != null ? ` · 剩余 ${formatBytes(plan.freeBytes, 1)}` : ""}
|
||||
</StatusBadge>
|
||||
{conflicts.length ? (
|
||||
<StatusBadge tone="warning">冲突 {conflicts.length}(跳过,不覆盖)</StatusBadge>
|
||||
) : null}
|
||||
{plan.pairs.some((p) => p.registered) ? <StatusBadge>含已登记资产,执行后同步位置</StatusBadge> : null}
|
||||
</div>
|
||||
{hnrBlocked ? (
|
||||
<Notice tone="destructive">
|
||||
{plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能 Move。可改用“复制”,或等 H&R 满足后再移动。
|
||||
<button
|
||||
type="button"
|
||||
className="ml-1 underline"
|
||||
onClick={() => {
|
||||
if (!target) return;
|
||||
setMode("copy");
|
||||
runPreview(target, "copy");
|
||||
}}
|
||||
>
|
||||
改为复制
|
||||
</button>
|
||||
</Notice>
|
||||
) : null}
|
||||
{plan.truncated ? <Notice tone="warning">源文件过多,预览只列出前一部分;执行覆盖全部。</Notice> : null}
|
||||
<ul className="space-y-1.5">
|
||||
{plan.pairs.map((p) => {
|
||||
const sr = rootById.get(p.source.root);
|
||||
const tr = rootById.get(p.target.root);
|
||||
return (
|
||||
<li
|
||||
key={`${p.source.root}:${p.source.path}`}
|
||||
className="rounded-lg border border-border/60 px-2.5 py-1.5"
|
||||
>
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1.5">
|
||||
{p.conflict ? (
|
||||
<StatusBadge tone="warning">目标已存在 · 跳过</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success">{mode === "move" ? "移动" : "复制"}</StatusBadge>
|
||||
)}
|
||||
<span className="text-meta text-muted-foreground">{formatBytes(p.size, 1)}</span>
|
||||
</div>
|
||||
<PathPair
|
||||
from={`${sr?.hostPath ?? p.source.root}/${p.source.path}`}
|
||||
to={`${tr?.hostPath ?? p.target.root}/${p.target.path}`}
|
||||
tone={p.conflict ? "conflict" : undefined}
|
||||
/>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={run} disabled={!canRun || exec.isPending}>
|
||||
{exec.isPending ? <LoaderCircle className="animate-spin" /> : mode === "move" ? <FolderInput /> : <Copy />}
|
||||
确认{mode === "move" ? "移动" : "复制"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 删除 ───────────────────────────────────────────────────────
|
||||
|
||||
export function DeleteDialog({
|
||||
roots,
|
||||
items,
|
||||
isAdmin,
|
||||
onClose,
|
||||
}: {
|
||||
roots: FileRoot[];
|
||||
items: FileRef[];
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [retire, setRetire] = useState(false);
|
||||
const [unbind, setUnbind] = useState(false);
|
||||
const [mode, setMode] = useState<"trash" | "permanent">("trash");
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const key = useMemo(() => newIdempotencyKey(), []);
|
||||
const check = useDelete();
|
||||
const exec = useDelete();
|
||||
const rootById = new Map(roots.map((r) => [r.id, r]));
|
||||
|
||||
const checkMutate = check.mutate;
|
||||
useEffect(() => {
|
||||
checkMutate(
|
||||
{ idempotencyKey: `${key}-check`, items, mode: "trash", dryRun: true, retireAssets: false, unbind: false },
|
||||
{ onError: (e) => setErr(filesErrorText(e, "引用检查失败")) },
|
||||
);
|
||||
}, [checkMutate, items, key]);
|
||||
|
||||
const checks = check.data?.checks ?? [];
|
||||
const hasAssets = checks.some((c) => c.assets.length > 0);
|
||||
const hasBindings = checks.some((c) => c.bindings.length > 0);
|
||||
const effective = (b: string | null) =>
|
||||
b === "needs_retire" && retire ? null : b === "needs_unbind" && unbind ? null : b;
|
||||
const hardBlocked = checks.filter((c) => {
|
||||
const b = effective(c.blocked);
|
||||
return b && !deleteBlockText(b)?.soft;
|
||||
});
|
||||
const softPending = checks.filter((c) => {
|
||||
const b = effective(c.blocked);
|
||||
return b && deleteBlockText(b)?.soft;
|
||||
});
|
||||
const single = items.length === 1 ? baseName(items[0]?.path ?? "") : null;
|
||||
const confirmPhrase = single ?? `永久删除 ${items.length} 项`;
|
||||
const permanentOk = mode !== "permanent" || confirmText.trim() === confirmPhrase;
|
||||
const deletable = checks.length - hardBlocked.length;
|
||||
const canRun = !!check.data && hardBlocked.length === 0 && softPending.length === 0 && permanentOk;
|
||||
|
||||
const run = () =>
|
||||
exec.mutate(
|
||||
{
|
||||
idempotencyKey: `${key}-${mode}`,
|
||||
items,
|
||||
mode,
|
||||
dryRun: false,
|
||||
retireAssets: retire,
|
||||
unbind,
|
||||
confirmName: mode === "permanent" && single ? confirmText.trim() : undefined,
|
||||
planId: check.data?.planId ?? undefined,
|
||||
},
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const n = mode === "trash" ? r.trashed.length : r.deleted.length;
|
||||
(r.failed.length ? toast.warning : toast.success)(
|
||||
mode === "trash" ? `已移入回收站 ${n} 项` : `已永久删除 ${n} 项`,
|
||||
{
|
||||
description: r.failed.map((f) => `${baseName(f.path)}:${f.message}`).join("\n") || undefined,
|
||||
action:
|
||||
mode === "trash"
|
||||
? { label: "回收站", onClick: () => window.location.assign("/console/files/trash") }
|
||||
: undefined,
|
||||
},
|
||||
);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => {
|
||||
const reason = filesErrorReason(e);
|
||||
setErr(
|
||||
reason === "permanent_disabled"
|
||||
? "系统设置未开启永久删除(files.allowPermanentDelete)"
|
||||
: reason === "approval_required"
|
||||
? "需要审批:API Key 删除每次都要一次性审批"
|
||||
: filesErrorText(e),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-h-[92dvh] grid-rows-[auto_minmax(0,1fr)_auto] sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{single ? `删除 ${single}?` : `删除 ${items.length} 项?`}</DialogTitle>
|
||||
<DialogDescription>
|
||||
删除前已做引用检查:做种任务、已登记资产、路径绑定。删除不会动下载器里的任务。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="min-h-0 space-y-3 overflow-y-auto">
|
||||
{check.isPending ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 引用检查中…
|
||||
</p>
|
||||
) : null}
|
||||
{err ? <Notice tone="destructive">{err}</Notice> : null}
|
||||
{checks.length ? (
|
||||
<ul className="space-y-1.5">
|
||||
{checks.map((c) => {
|
||||
const b = deleteBlockText(effective(c.blocked));
|
||||
const r = rootById.get(c.root);
|
||||
return (
|
||||
<li key={`${c.root}:${c.path}`} className="rounded-lg border border-border/60 px-2.5 py-2">
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{b ? (
|
||||
<StatusBadge tone={b.soft ? "warning" : "destructive"}>
|
||||
{b.soft ? "需确认" : "不能删除"}
|
||||
</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success">可删除</StatusBadge>
|
||||
)}
|
||||
<span className="min-w-0 truncate text-caption text-foreground">
|
||||
{baseName(c.path) || r?.name}
|
||||
</span>
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{c.isDir ? "目录 · " : ""}
|
||||
{formatBytes(c.size, 1)}
|
||||
</span>
|
||||
</div>
|
||||
{b ? (
|
||||
<p className={cn("mt-1 text-meta", b.soft ? "text-warning" : "text-destructive")}>{b.text}</p>
|
||||
) : null}
|
||||
<div className="mt-1 space-y-0.5 text-meta text-muted-foreground">
|
||||
{c.downloads.map((d) => (
|
||||
<p key={d.taskId}>
|
||||
下载任务{" "}
|
||||
<Link className="underline hover:text-foreground" to={`/console/tasks/${d.taskId}`}>
|
||||
{d.taskId.slice(0, 8)}
|
||||
</Link>{" "}
|
||||
· {d.status} · H&R{" "}
|
||||
{d.hnr === "satisfied" ? "已满足" : d.hnr === "unsatisfied" ? "未满足" : "未知"}
|
||||
</p>
|
||||
))}
|
||||
{c.assets.length ? (
|
||||
<p>已登记资产 {c.assets.length} 个(删除后退役,满足的 Slot 退回待处理)</p>
|
||||
) : null}
|
||||
{c.bindings.map((bd) => (
|
||||
<p key={bd.bindingId}>
|
||||
路径绑定:{bd.title}
|
||||
{bd.seasonNumber != null ? ` 第 ${bd.seasonNumber} 季` : ""}
|
||||
</p>
|
||||
))}
|
||||
{c.sharedInode ? <p>共享 inode(硬链接):只删除这一处链接,其他位置的文件不受影响</p> : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : null}
|
||||
{hasAssets ? (
|
||||
<label className="flex items-start gap-2.5 text-caption">
|
||||
<input type="checkbox" className="mt-1" checked={retire} onChange={(e) => setRetire(e.target.checked)} />
|
||||
<span>
|
||||
同时退役资产
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
资产位置标为已移除、资产退役;满足的 Slot 退回待处理并建待处理事项。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{hasBindings ? (
|
||||
<label className="flex items-start gap-2.5 text-caption">
|
||||
<input type="checkbox" className="mt-1" checked={unbind} onChange={(e) => setUnbind(e.target.checked)} />
|
||||
<span>
|
||||
解除路径绑定
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
绑定标为已替代;之后该作品入库会重新选择目录。
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
{checks.length && deletable > 0 ? (
|
||||
<div className="space-y-2 rounded-xl border border-border p-3">
|
||||
<label className="flex items-start gap-2.5 text-caption">
|
||||
<input
|
||||
type="radio"
|
||||
name="del-mode"
|
||||
className="mt-1"
|
||||
checked={mode === "trash"}
|
||||
onChange={() => setMode("trash")}
|
||||
/>
|
||||
<span>
|
||||
移入回收站
|
||||
<span className="block text-meta text-muted-foreground">保留 7 天,期间可在回收站还原到原位置。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label
|
||||
className={cn("flex items-start gap-2.5 text-caption", !isAdmin && "opacity-50")}
|
||||
title={isAdmin ? undefined : "仅管理员会话可永久删除"}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="del-mode"
|
||||
className="mt-1"
|
||||
disabled={!isAdmin}
|
||||
checked={mode === "permanent"}
|
||||
onChange={() => setMode("permanent")}
|
||||
/>
|
||||
<span>
|
||||
永久删除
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{isAdmin ? "不可恢复;需在系统设置开启后才可用。" : "仅管理员会话可用。"}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
{mode === "permanent" ? (
|
||||
<FormField
|
||||
label={
|
||||
<>
|
||||
输入 <span className="font-mono">{confirmPhrase}</span> 以确认
|
||||
</>
|
||||
}
|
||||
htmlFor="del-confirm"
|
||||
>
|
||||
<Input
|
||||
id="del-confirm"
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</FormField>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={run} disabled={!canRun || exec.isPending}>
|
||||
{exec.isPending ? <LoaderCircle className="animate-spin" /> : <Trash2 />}
|
||||
{mode === "trash" ? "移入回收站" : "永久删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 复制路径 ───────────────────────────────────────────────────
|
||||
|
||||
export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) {
|
||||
const stat = useFileStat(root.id, entry.path);
|
||||
const local = `${root.hostPath}${entry.path ? `/${entry.path}` : ""}`;
|
||||
const rows = [
|
||||
{ label: "本机路径", value: local },
|
||||
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
|
||||
{ label: "根内相对路径", value: entry.path || "(根)" },
|
||||
];
|
||||
const copy = async (v: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(v);
|
||||
toast.success("已复制");
|
||||
} catch {
|
||||
toast.error("浏览器不允许写剪贴板");
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>复制路径</DialogTitle>
|
||||
<DialogDescription className="break-all">{entry.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<ul className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<li key={r.label} className="flex items-center gap-2 rounded-lg border border-border/60 px-3 py-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-meta text-muted-foreground">{r.label}</p>
|
||||
<p className="font-mono text-caption break-all">{r.value ?? (stat.isPending ? "…" : "—")}</p>
|
||||
</div>
|
||||
{r.value ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`复制${r.label}`}
|
||||
onClick={() => copy(r.value as string)}
|
||||
>
|
||||
<Copy />
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
下载器与媒体服务器视角的路径按系统设置里的路径映射换算,见“系统设置 → 路径映射”。
|
||||
</p>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 刮削 ───────────────────────────────────────────────────────
|
||||
|
||||
export function ScrapeDialog({ entry, onClose }: { entry: FileEntry; onClose: () => void }) {
|
||||
const [writeNfo, setWriteNfo] = useState(false);
|
||||
const [images, setImages] = useState(false);
|
||||
const scrape = useScrape();
|
||||
const key = useMemo(() => newIdempotencyKey(), []);
|
||||
const asset = entry.asset;
|
||||
if (!asset) return null;
|
||||
const run = () =>
|
||||
scrape.mutate(
|
||||
{ idempotencyKey: key, mediaEntityId: asset.mediaEntityId, writeNfo, images },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const extra =
|
||||
r.nfo === "not_implemented" || r.images === "not_implemented"
|
||||
? "NFO/图片投影尚未实现,本次只刷新了字段。"
|
||||
: r.nfo === "skipped"
|
||||
? "目标 NFO 不是本系统生成且字段已锁定,未覆盖。"
|
||||
: "";
|
||||
toast.success(`已提交元数据刷新${r.lockedFields.length ? `,${r.lockedFields.length} 个锁定字段不动` : ""}`, {
|
||||
description: extra || undefined,
|
||||
action: { label: "查看运行", onClick: () => window.location.assign(`/console/engine/${r.taskId}`) },
|
||||
});
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "刮削失败")),
|
||||
},
|
||||
);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>刮削 {asset.title}</DialogTitle>
|
||||
<DialogDescription>
|
||||
字段级刷新元数据:用户锁定的字段不动,不改路径。非本系统生成的 NFO 不会被覆盖。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center justify-between gap-3 text-caption">
|
||||
写 NFO 到绑定目录
|
||||
<Switch checked={writeNfo} onCheckedChange={setWriteNfo} aria-label="写 NFO 到绑定目录" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3 text-caption">
|
||||
下载海报/背景图
|
||||
<Switch checked={images} onCheckedChange={setImages} aria-label="下载海报/背景图" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={run} disabled={scrape.isPending}>
|
||||
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
|
||||
刮削
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type FileRoot, useDirChildren } from "~/api/files.ts";
|
||||
import { healthLabel } from "~/lib/labels.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { joinRel, segments } from "./model.ts";
|
||||
|
||||
type Loc = { root: string; path: string };
|
||||
type DropHandler = (target: Loc, e: React.DragEvent) => void;
|
||||
|
||||
const nodeKey = (root: string, path: string) => `${root}\u0000${path}`;
|
||||
|
||||
/**
|
||||
* 根列表 + 懒加载目录树。按 库 / 下载 分组,每根带健康徽标与剩余空间;
|
||||
* 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。
|
||||
* pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。
|
||||
*/
|
||||
export function DirTree({
|
||||
roots,
|
||||
current,
|
||||
onSelect,
|
||||
onDropTo,
|
||||
pickOnly = false,
|
||||
defaultOpen = [],
|
||||
className,
|
||||
}: {
|
||||
roots: FileRoot[];
|
||||
current: Loc | null;
|
||||
onSelect: (loc: Loc) => void;
|
||||
onDropTo?: DropHandler;
|
||||
pickOnly?: boolean;
|
||||
/** 初始展开的根 id(目标选择器里默认展开可写根) */
|
||||
defaultOpen?: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen.map((id) => nodeKey(id, ""))));
|
||||
|
||||
// 当前路径的各级祖先自动展开
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
const segs = segments(current.path);
|
||||
next.add(nodeKey(current.root, ""));
|
||||
for (let i = 1; i < segs.length; i++) next.add(nodeKey(current.root, segs.slice(0, i).join("/")));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [current]);
|
||||
|
||||
const toggle = (k: string) =>
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(k)) next.delete(k);
|
||||
else next.add(k);
|
||||
return next;
|
||||
});
|
||||
|
||||
const groups = [
|
||||
{ title: "媒体库", items: roots.filter((r) => r.kind === "library") },
|
||||
{ title: "下载目录", items: roots.filter((r) => r.kind === "download") },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<nav aria-label="根与目录" className={cn("space-y-4", className)}>
|
||||
{groups.map((g) => (
|
||||
<section key={g.title} aria-label={g.title}>
|
||||
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
|
||||
{g.title}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{g.items.map((r) => (
|
||||
<RootNode
|
||||
key={r.id}
|
||||
root={r}
|
||||
current={current}
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onDropTo={onDropTo}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNode({
|
||||
root,
|
||||
current,
|
||||
open,
|
||||
toggle,
|
||||
onSelect,
|
||||
onDropTo,
|
||||
pickOnly,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
current: Loc | null;
|
||||
open: Set<string>;
|
||||
toggle: (k: string) => void;
|
||||
onSelect: (loc: Loc) => void;
|
||||
onDropTo?: DropHandler;
|
||||
pickOnly: boolean;
|
||||
}) {
|
||||
const k = nodeKey(root.id, "");
|
||||
const offline = root.health === "offline";
|
||||
const disabled = pickOnly && (!root.writable || offline);
|
||||
const isOpen = open.has(k) && !offline;
|
||||
const active = current?.root === root.id && current.path === "";
|
||||
const h = healthLabel(root.health);
|
||||
const Icon = root.kind === "download" ? Download : HardDrive;
|
||||
return (
|
||||
<div>
|
||||
<TreeRow
|
||||
depth={0}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
expandable={!offline}
|
||||
expanded={isOpen}
|
||||
onToggle={() => toggle(k)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelect({ root: root.id, path: "" });
|
||||
if (!open.has(k)) toggle(k);
|
||||
}}
|
||||
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
|
||||
title={disabled ? (offline ? "根离线" : "只读根,不能作为目标") : root.hostPath}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body text-foreground">{root.name}</span>
|
||||
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
{root.health !== "healthy" ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
h.tone === "destructive" && "text-destructive",
|
||||
h.tone === "warning" && "text-warning",
|
||||
)}
|
||||
>
|
||||
{h.tone !== "neutral" ? <TriangleAlert className="h-3 w-3" /> : null}
|
||||
{h.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
正常
|
||||
</span>
|
||||
)}
|
||||
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
|
||||
{!root.writable ? <span>只读</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</TreeRow>
|
||||
{isOpen ? (
|
||||
<Children
|
||||
root={root}
|
||||
path=""
|
||||
depth={1}
|
||||
current={current}
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onDropTo={onDropTo}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Children({
|
||||
root,
|
||||
path,
|
||||
depth,
|
||||
current,
|
||||
open,
|
||||
toggle,
|
||||
onSelect,
|
||||
onDropTo,
|
||||
pickOnly,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
path: string;
|
||||
depth: number;
|
||||
current: Loc | null;
|
||||
open: Set<string>;
|
||||
toggle: (k: string) => void;
|
||||
onSelect: (loc: Loc) => void;
|
||||
onDropTo?: DropHandler;
|
||||
pickOnly: boolean;
|
||||
}) {
|
||||
const q = useDirChildren(root.id, path, true);
|
||||
if (q.isPending)
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
|
||||
style={{ paddingLeft: depth * 14 + 28 }}
|
||||
>
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中
|
||||
</div>
|
||||
);
|
||||
if (q.error)
|
||||
return (
|
||||
<p className="py-1 text-meta text-destructive" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
读取失败
|
||||
</p>
|
||||
);
|
||||
const dirs = q.data?.dirs ?? [];
|
||||
if (!dirs.length)
|
||||
return (
|
||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
无子目录
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{dirs.map((d) => {
|
||||
const p = d.path || joinRel(path, d.name);
|
||||
const k = nodeKey(root.id, p);
|
||||
const isOpen = open.has(k);
|
||||
const active = current?.root === root.id && current.path === p;
|
||||
const disabled = pickOnly && !root.writable;
|
||||
return (
|
||||
<div key={k}>
|
||||
<TreeRow
|
||||
depth={depth}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
expandable
|
||||
expanded={isOpen}
|
||||
onToggle={() => toggle(k)}
|
||||
onClick={() => !disabled && onSelect({ root: root.id, path: p })}
|
||||
onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined}
|
||||
title={d.name}
|
||||
>
|
||||
{isOpen ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
|
||||
</TreeRow>
|
||||
{isOpen ? (
|
||||
<Children
|
||||
root={root}
|
||||
path={p}
|
||||
depth={depth + 1}
|
||||
current={current}
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onDropTo={onDropTo}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{q.data?.truncated ? (
|
||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
只显示前 1000 个子目录
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
depth,
|
||||
active,
|
||||
disabled,
|
||||
expandable,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDrop,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
depth: number;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
expandable: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [over, setOver] = useState(false);
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: 整行是拖放目标,交互本体是里面的两个按钮
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-lg pr-2 transition-colors duration-micro",
|
||||
active
|
||||
? "pill-selected text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
||||
disabled && "opacity-50",
|
||||
over && "bg-foreground/[0.08] ring-1 ring-ring",
|
||||
)}
|
||||
style={{ paddingLeft: depth * 14 }}
|
||||
onDragOver={
|
||||
onDrop
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
|
||||
setOver(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={onDrop ? () => setOver(false) : undefined}
|
||||
onDrop={
|
||||
onDrop
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
setOver(false);
|
||||
onDrop(e);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={expanded ? "收起" : "展开"}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
className={cn("grid h-7 w-6 shrink-0 place-items-center rounded-md", !expandable && "invisible")}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", expanded && "rotate-90")} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-current={active ? "location" : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left outline-none focus-visible:underline",
|
||||
disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Download, ExternalLink, LoaderCircle, ScanSearch } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileRoot,
|
||||
fileDownloadUrl,
|
||||
filesErrorText,
|
||||
filesMockEnabled,
|
||||
useFileStat,
|
||||
useProbe,
|
||||
useTextPreview,
|
||||
} from "~/api/files.ts";
|
||||
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
|
||||
import { LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { KvList } from "~/components/console/kv-list.tsx";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { downloadLabel } from "~/lib/labels.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
import { EntryStatus } from "./file-list.tsx";
|
||||
import { baseName } from "./model.ts";
|
||||
|
||||
const PREVIEW_MAX = 20 * 1024 * 1024;
|
||||
|
||||
/** 选中单项的详情抽屉:文件信息、作品/Slot/下载链接、sidecars、探测摘要、路径绑定与小文件预览 */
|
||||
export function FileDetail({
|
||||
root,
|
||||
entry,
|
||||
onClose,
|
||||
actions,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
entry: FileEntry;
|
||||
onClose: () => void;
|
||||
actions?: ReactNode;
|
||||
}) {
|
||||
const stat = useFileStat(root.id, entry.path);
|
||||
const probe = useProbe();
|
||||
const e = stat.data?.entry ?? entry;
|
||||
const previewable =
|
||||
e.type === "file" &&
|
||||
e.size <= PREVIEW_MAX &&
|
||||
(e.fileKind === "nfo" || e.fileKind === "subtitle" || e.fileKind === "image");
|
||||
const text = useTextPreview(root.id, e.path, previewable && e.fileKind !== "image");
|
||||
const media = probe.data?.items[0]?.summary ?? e.media;
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open
|
||||
onOpenChange={(o) => !o && onClose()}
|
||||
title={e.name}
|
||||
description={<EntryStatus e={e} />}
|
||||
footer={actions}
|
||||
>
|
||||
<DetailSection title="文件">
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "类型", value: e.type === "dir" ? "目录" : e.type === "symlink" ? "符号链接" : (e.ext ?? "文件") },
|
||||
{
|
||||
label: "大小",
|
||||
value:
|
||||
e.type === "dir"
|
||||
? e.dirSummary
|
||||
? `${e.dirSummary.videoCount}${e.dirSummary.partial ? "+" : ""} 个视频 · ${e.dirSummary.registeredCount} 个已登记`
|
||||
: "—"
|
||||
: formatBytes(e.size, 2),
|
||||
},
|
||||
{ label: "修改时间", value: formatDateTime(e.mtime) },
|
||||
{
|
||||
label: "链接数",
|
||||
value:
|
||||
stat.data?.nlink != null
|
||||
? `${stat.data.nlink}${stat.data.sharedInode ? "(共享 inode,禁止原地改写)" : ""}`
|
||||
: null,
|
||||
},
|
||||
{ label: "本机路径", value: `${root.hostPath}${e.path ? `/${e.path}` : ""}`, mono: true, wide: true },
|
||||
...(stat.data && stat.data.realPath !== `${root.hostPath}/${e.path}`
|
||||
? [{ label: "真实路径", value: stat.data.realPath, mono: true, wide: true }]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
{stat.isPending ? <LoadingState rows={1} /> : null}
|
||||
{stat.error ? (
|
||||
<p className="mt-2 text-meta text-destructive">{filesErrorText(stat.error, "读取详情失败")}</p>
|
||||
) : null}
|
||||
</DetailSection>
|
||||
|
||||
{e.asset ? (
|
||||
<DetailSection title="已登记资产">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "作品",
|
||||
value: (
|
||||
<Link
|
||||
className="underline underline-offset-2 hover:text-foreground"
|
||||
to={`/console/library/${e.asset.mediaEntityId}`}
|
||||
>
|
||||
{e.asset.title}
|
||||
{e.asset.year ? ` (${e.asset.year})` : ""}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{ label: "内容单元", value: e.asset.contentUnit ?? "整部" },
|
||||
{
|
||||
label: "Slot",
|
||||
value: e.asset.slotId ? (
|
||||
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
|
||||
{e.asset.slotStatus ?? "查看"}
|
||||
</Link>
|
||||
) : (
|
||||
"未满足任何 Slot"
|
||||
),
|
||||
},
|
||||
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
|
||||
]}
|
||||
/>
|
||||
<Link
|
||||
className="mt-2 inline-block text-meta text-muted-foreground underline"
|
||||
to={`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`}
|
||||
>
|
||||
在整理记录中查看
|
||||
</Link>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{e.download ? (
|
||||
<DetailSection title="下载任务">
|
||||
<KvList
|
||||
items={[
|
||||
{
|
||||
label: "任务",
|
||||
value: (
|
||||
<Link className="underline underline-offset-2" to={`/console/tasks/${e.download.taskId}`}>
|
||||
{downloadLabel(e.download.status).label}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: "进度",
|
||||
value: e.download.progress != null ? `${Math.floor(e.download.progress * 100)}%` : null,
|
||||
},
|
||||
{
|
||||
label: "H&R",
|
||||
value: e.download.hnr === "satisfied" ? "已满足" : e.download.hnr === "unsatisfied" ? "未满足" : "未知",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{stat.data?.bindings.length ? (
|
||||
<DetailSection title="路径绑定">
|
||||
<ul className="space-y-1 text-caption">
|
||||
{stat.data.bindings.map((b) => (
|
||||
<li key={b.bindingId}>
|
||||
<Link className="underline underline-offset-2" to={`/console/library/${b.mediaEntityId}`}>
|
||||
{b.title}
|
||||
</Link>
|
||||
{b.kind === "season" && b.seasonNumber != null ? ` · 第 ${b.seasonNumber} 季` : " · 作品目录"}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="mt-1 text-meta text-muted-foreground">改名/移动此目录会同步修订绑定;元数据刷新不会改路径。</p>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{stat.data?.sidecars.length ? (
|
||||
<DetailSection title={`附属文件 ${stat.data.sidecars.length}`}>
|
||||
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
|
||||
{stat.data.sidecars.map((s) => (
|
||||
<li key={s} className="truncate" title={s}>
|
||||
{baseName(s)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{e.type === "file" && e.fileKind === "video" ? (
|
||||
<DetailSection
|
||||
title="媒体信息"
|
||||
actions={
|
||||
<Button
|
||||
size="xs"
|
||||
variant="outline"
|
||||
disabled={probe.isPending}
|
||||
onClick={() =>
|
||||
probe.mutate(
|
||||
{ items: [{ root: root.id, path: e.path }] },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const it = r.items[0];
|
||||
if (it?.status !== "ok") toast.warning(`探测不到媒体信息:${it?.reason ?? "未知原因"}`);
|
||||
},
|
||||
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
||||
},
|
||||
)
|
||||
}
|
||||
>
|
||||
{probe.isPending ? <LoaderCircle className="animate-spin" /> : <ScanSearch />}
|
||||
探测
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{media ? (
|
||||
<KvList
|
||||
items={[
|
||||
{ label: "分辨率", value: media.resolution },
|
||||
{ label: "HDR", value: media.hdr },
|
||||
{ label: "视频编码", value: media.videoCodec },
|
||||
{ label: "音轨", value: media.audioTracks },
|
||||
{
|
||||
label: "时长",
|
||||
value: media.durationSec ? `${Math.floor(media.durationSec / 60)} 分钟` : null,
|
||||
},
|
||||
...(probe.data?.items[0]
|
||||
? [
|
||||
{ label: "容器", value: probe.data.items[0].container },
|
||||
{ label: "字幕轨", value: probe.data.items[0].subtitleTracks },
|
||||
]
|
||||
: []),
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-caption text-muted-foreground">
|
||||
未登记文件不自动探测;点“探测”读取 ffprobe 摘要(缓存 24 小时)。
|
||||
</p>
|
||||
)}
|
||||
</DetailSection>
|
||||
) : null}
|
||||
|
||||
{previewable ? (
|
||||
<DetailSection
|
||||
title="预览"
|
||||
actions={
|
||||
filesMockEnabled() ? null : (
|
||||
<a
|
||||
className={buttonVariants({ size: "xs", variant: "outline" })}
|
||||
href={fileDownloadUrl(root.id, e.path)}
|
||||
download={e.name}
|
||||
>
|
||||
<Download />
|
||||
下载
|
||||
</a>
|
||||
)
|
||||
}
|
||||
>
|
||||
{e.fileKind === "image" ? (
|
||||
filesMockEnabled() ? (
|
||||
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
|
||||
) : (
|
||||
<a href={fileDownloadUrl(root.id, e.path)} target="_blank" rel="noreferrer" className="block">
|
||||
<img
|
||||
src={fileDownloadUrl(root.id, e.path)}
|
||||
alt={e.name}
|
||||
className="max-h-80 rounded-xl border border-border object-contain"
|
||||
/>
|
||||
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
|
||||
<ExternalLink className="h-3 w-3" /> 新窗口打开
|
||||
</span>
|
||||
</a>
|
||||
)
|
||||
) : text.isPending ? (
|
||||
<LoadingState rows={3} />
|
||||
) : text.error ? (
|
||||
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
|
||||
) : (
|
||||
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
|
||||
{text.data}
|
||||
</pre>
|
||||
)}
|
||||
</DetailSection>
|
||||
) : null}
|
||||
</DetailSheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
import {
|
||||
Check,
|
||||
File,
|
||||
FileImage,
|
||||
FileText,
|
||||
FileVideo,
|
||||
Folder,
|
||||
Link2Off,
|
||||
LoaderCircle,
|
||||
MoreHorizontal,
|
||||
Music,
|
||||
Subtitles,
|
||||
} from "lucide-react";
|
||||
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type { FileEntry } from "~/api/files.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import type { MenuAnchor } from "./context-menu.tsx";
|
||||
import { clickSelect, entryStatusView, moveFocus, type Selection, selectAll, toggleSelect } from "./model.ts";
|
||||
|
||||
export const ROW_H = 52;
|
||||
export const CARD_H = 68;
|
||||
|
||||
function EntryIcon({ e }: { e: FileEntry }) {
|
||||
const cls = "h-4 w-4 shrink-0 text-muted-foreground";
|
||||
if (e.escape) return <Link2Off className={cn(cls, "text-destructive")} />;
|
||||
if (e.type === "dir") return <Folder className={cls} />;
|
||||
switch (e.fileKind) {
|
||||
case "video":
|
||||
return <FileVideo className={cls} />;
|
||||
case "subtitle":
|
||||
return <Subtitles className={cls} />;
|
||||
case "image":
|
||||
return <FileImage className={cls} />;
|
||||
case "nfo":
|
||||
return <FileText className={cls} />;
|
||||
case "audio":
|
||||
return <Music className={cls} />;
|
||||
default:
|
||||
return <File className={cls} />;
|
||||
}
|
||||
}
|
||||
|
||||
function sizeCell(e: FileEntry): ReactNode {
|
||||
if (e.type === "dir") {
|
||||
const s = e.dirSummary;
|
||||
if (!s) return <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className="text-muted-foreground" title="视频数 / 已登记数">
|
||||
{s.videoCount}
|
||||
{s.partial ? "+" : ""} 视频 · {s.registeredCount} 登记
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return formatBytes(e.size, 1);
|
||||
}
|
||||
|
||||
/** 状态徽标 + 链接(作品 / 下载任务) */
|
||||
export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: boolean }) {
|
||||
const v = entryStatusView(e);
|
||||
if (!v) return compact ? null : <span className="text-muted-foreground">—</span>;
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5" title={v.hint}>
|
||||
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
|
||||
{!compact && e.download ? (
|
||||
<Link
|
||||
to={`/console/tasks/${e.download.taskId}`}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="truncate text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
|
||||
>
|
||||
任务
|
||||
</Link>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function useScrollBox(ref: React.RefObject<HTMLDivElement | null>) {
|
||||
const [box, setBox] = useState({ top: 0, height: 600 });
|
||||
useLayoutEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const update = () => setBox({ top: el.scrollTop, height: el.clientHeight });
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
return () => {
|
||||
ro.disconnect();
|
||||
el.removeEventListener("scroll", update);
|
||||
};
|
||||
}, [ref]);
|
||||
return box;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件列表:role="grid" 的虚拟滚动表格(固定行高,只渲染可见行 ± overscan)。
|
||||
* 桌面为多列行;窄屏(mobile)为单列卡片,长按进入多选。
|
||||
* 键盘:↑↓/Home/End 移动焦点(Shift 扩选)、Enter 打开、Backspace 上一级、Space 勾选、
|
||||
* Ctrl/Cmd+A 全选、F2 改名、Delete 删除、Esc 清除选择、Shift+F10/菜单键 打开右键菜单。
|
||||
*/
|
||||
export function FileList({
|
||||
entries,
|
||||
keyOf,
|
||||
selection,
|
||||
onSelection,
|
||||
onOpen,
|
||||
onUp,
|
||||
onMenu,
|
||||
onRename,
|
||||
onDelete,
|
||||
onDragRows,
|
||||
onDropToDir,
|
||||
hasMore,
|
||||
onLoadMore,
|
||||
loadingMore,
|
||||
mobile,
|
||||
footer,
|
||||
gridRef,
|
||||
}: {
|
||||
entries: FileEntry[];
|
||||
keyOf: (e: FileEntry) => string;
|
||||
selection: Selection;
|
||||
onSelection: (s: Selection) => void;
|
||||
onOpen: (e: FileEntry) => void;
|
||||
onUp: () => void;
|
||||
onMenu: (e: FileEntry, at: MenuAnchor) => void;
|
||||
onRename: (e: FileEntry) => void;
|
||||
onDelete: (entries: FileEntry[]) => void;
|
||||
onDragRows: (e: FileEntry, ev: React.DragEvent) => void;
|
||||
onDropToDir: (dir: FileEntry, ev: React.DragEvent) => void;
|
||||
hasMore: boolean;
|
||||
onLoadMore: () => void;
|
||||
loadingMore: boolean;
|
||||
mobile: boolean;
|
||||
footer?: ReactNode;
|
||||
gridRef?: React.RefObject<HTMLDivElement | null>;
|
||||
}) {
|
||||
const innerRef = useRef<HTMLDivElement>(null);
|
||||
const ref = gridRef ?? innerRef;
|
||||
const box = useScrollBox(ref);
|
||||
const rowH = mobile ? CARD_H : ROW_H;
|
||||
const order = entries.map(keyOf);
|
||||
const byKey = new Map(entries.map((e) => [keyOf(e), e]));
|
||||
const overscan = 8;
|
||||
const start = Math.max(0, Math.floor(box.top / rowH) - overscan);
|
||||
const end = Math.min(entries.length, Math.ceil((box.top + box.height) / rowH) + overscan);
|
||||
const focusIndex = selection.focus ? order.indexOf(selection.focus) : -1;
|
||||
const [dropOver, setDropOver] = useState<string | null>(null);
|
||||
const longPress = useRef<{ t: ReturnType<typeof setTimeout> | null; fired: boolean }>({ t: null, fired: false });
|
||||
const selecting = mobile && selection.keys.size > 0;
|
||||
|
||||
// 滚到底自动取下一页
|
||||
useEffect(() => {
|
||||
if (hasMore && !loadingMore && end >= entries.length - 20) onLoadMore();
|
||||
}, [hasMore, loadingMore, end, entries.length, onLoadMore]);
|
||||
|
||||
const scrollToIndex = useCallback(
|
||||
(i: number) => {
|
||||
const el = ref.current;
|
||||
if (!el || i < 0) return;
|
||||
const top = i * rowH;
|
||||
if (top < el.scrollTop) el.scrollTop = top;
|
||||
else if (top + rowH > el.scrollTop + el.clientHeight) el.scrollTop = top + rowH - el.clientHeight;
|
||||
},
|
||||
[ref, rowH],
|
||||
);
|
||||
|
||||
const selectedEntries = () =>
|
||||
selection.keys.size
|
||||
? entries.filter((e) => selection.keys.has(keyOf(e)))
|
||||
: selection.focus && byKey.get(selection.focus)
|
||||
? [byKey.get(selection.focus) as FileEntry]
|
||||
: [];
|
||||
|
||||
const onKeyDown = (ev: React.KeyboardEvent) => {
|
||||
if ((ev.target as HTMLElement).closest("input,textarea,[role=menu]")) return;
|
||||
const focused = selection.focus ? byKey.get(selection.focus) : undefined;
|
||||
const mod = ev.ctrlKey || ev.metaKey;
|
||||
const step = (delta: number) => {
|
||||
ev.preventDefault();
|
||||
const next = moveFocus(selection, order, delta, ev.shiftKey);
|
||||
onSelection(
|
||||
ev.shiftKey
|
||||
? next
|
||||
: { ...next, keys: mod ? next.keys : new Set(next.focus ? [next.focus] : []), anchor: next.focus },
|
||||
);
|
||||
scrollToIndex(next.focus ? order.indexOf(next.focus) : -1);
|
||||
};
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
return step(1);
|
||||
case "ArrowUp":
|
||||
return step(-1);
|
||||
case "PageDown":
|
||||
return step(Math.max(1, Math.floor(box.height / rowH) - 1));
|
||||
case "PageUp":
|
||||
return step(-Math.max(1, Math.floor(box.height / rowH) - 1));
|
||||
case "Home":
|
||||
return step(-order.length);
|
||||
case "End":
|
||||
return step(order.length);
|
||||
case "Enter":
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
onOpen(focused);
|
||||
}
|
||||
return;
|
||||
case "Backspace":
|
||||
ev.preventDefault();
|
||||
onUp();
|
||||
return;
|
||||
case " ":
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
onSelection(toggleSelect(selection, keyOf(focused)));
|
||||
}
|
||||
return;
|
||||
case "F2":
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
onRename(focused);
|
||||
}
|
||||
return;
|
||||
case "Delete": {
|
||||
const sel = selectedEntries();
|
||||
if (sel.length) {
|
||||
ev.preventDefault();
|
||||
onDelete(sel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
case "Escape":
|
||||
if (selection.keys.size) {
|
||||
ev.preventDefault();
|
||||
onSelection({ ...selection, keys: new Set() });
|
||||
}
|
||||
return;
|
||||
case "ContextMenu":
|
||||
case "F10":
|
||||
if (focused && (ev.key === "ContextMenu" || ev.shiftKey)) {
|
||||
ev.preventDefault();
|
||||
const row = ref.current?.querySelector<HTMLElement>(`[data-row-key="${CSS.escape(keyOf(focused))}"]`);
|
||||
const r = row?.getBoundingClientRect();
|
||||
onMenu(focused, { x: (r?.left ?? 0) + 48, y: (r?.bottom ?? 0) - 4 });
|
||||
}
|
||||
return;
|
||||
default:
|
||||
if ((ev.key === "a" || ev.key === "A") && mod) {
|
||||
ev.preventDefault();
|
||||
onSelection(selectAll(order, selection));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onRowClick = (e: FileEntry, ev: React.MouseEvent) => {
|
||||
const k = keyOf(e);
|
||||
if (longPress.current.fired) {
|
||||
longPress.current.fired = false;
|
||||
return;
|
||||
}
|
||||
if (selecting) {
|
||||
onSelection(toggleSelect(selection, k));
|
||||
return;
|
||||
}
|
||||
if (mobile) {
|
||||
onOpen(e);
|
||||
return;
|
||||
}
|
||||
onSelection(clickSelect(selection, order, k, { shift: ev.shiftKey, ctrl: ev.ctrlKey || ev.metaKey }));
|
||||
ref.current?.focus({ preventScroll: true });
|
||||
};
|
||||
|
||||
const touchStart = (e: FileEntry) => {
|
||||
if (!mobile) return;
|
||||
longPress.current.fired = false;
|
||||
longPress.current.t = setTimeout(() => {
|
||||
longPress.current.fired = true;
|
||||
onSelection(toggleSelect(selection, keyOf(e)));
|
||||
if (typeof navigator !== "undefined" && "vibrate" in navigator) navigator.vibrate?.(10);
|
||||
}, 480);
|
||||
};
|
||||
const touchEnd = () => {
|
||||
if (longPress.current.t) clearTimeout(longPress.current.t);
|
||||
longPress.current.t = null;
|
||||
};
|
||||
|
||||
const visible = entries.slice(start, end);
|
||||
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 虚拟滚动网格(绝对定位行)无法用 table 元素
|
||||
<div
|
||||
ref={ref}
|
||||
role="grid"
|
||||
aria-label="文件列表"
|
||||
aria-multiselectable
|
||||
aria-rowcount={entries.length}
|
||||
aria-activedescendant={focusIndex >= 0 ? `file-row-${focusIndex}` : undefined}
|
||||
tabIndex={0}
|
||||
onKeyDown={onKeyDown}
|
||||
onFocus={() => {
|
||||
if (!selection.focus && order[0]) onSelection({ ...selection, focus: order[0] });
|
||||
}}
|
||||
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset"
|
||||
>
|
||||
{!mobile ? (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 同上,虚拟网格的表头行
|
||||
// biome-ignore lint/a11y/useFocusableInteractive: 焦点在 grid 容器上
|
||||
<div
|
||||
role="row"
|
||||
className={cn(
|
||||
"sticky top-0 z-10 grid items-center gap-3 border-b border-border bg-background/95 px-3 py-2 text-meta font-medium text-muted-foreground backdrop-blur",
|
||||
DESKTOP_COLS,
|
||||
)}
|
||||
>
|
||||
<Cell header className="grid place-items-center">
|
||||
<Checkbox
|
||||
checked={entries.length > 0 && selection.keys.size === entries.length}
|
||||
partial={selection.keys.size > 0 && selection.keys.size < entries.length}
|
||||
label="全选"
|
||||
onToggle={() =>
|
||||
onSelection(
|
||||
selection.keys.size === entries.length
|
||||
? { ...selection, keys: new Set() }
|
||||
: selectAll(order, selection),
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell header>名称</Cell>
|
||||
<Cell header>状态</Cell>
|
||||
<Cell header className="hidden xl:block">
|
||||
季集
|
||||
</Cell>
|
||||
<Cell header className="text-right">
|
||||
大小
|
||||
</Cell>
|
||||
<Cell header className="hidden lg:block">
|
||||
修改时间
|
||||
</Cell>
|
||||
<Cell header className="sr-only">
|
||||
操作
|
||||
</Cell>
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ height: entries.length * rowH, position: "relative" }}>
|
||||
{visible.map((e, j) => {
|
||||
const i = start + j;
|
||||
const k = keyOf(e);
|
||||
const selected = selection.keys.has(k);
|
||||
const focused = selection.focus === k;
|
||||
const isDropDir = e.type === "dir" && !e.escape;
|
||||
const common = {
|
||||
id: `file-row-${i}`,
|
||||
role: "row",
|
||||
"aria-rowindex": i + 1,
|
||||
"aria-selected": selected,
|
||||
"data-row-key": k,
|
||||
draggable: !mobile && !e.escape,
|
||||
onDragStart: (ev: React.DragEvent) => onDragRows(e, ev),
|
||||
onDragOver: isDropDir
|
||||
? (ev: React.DragEvent) => {
|
||||
if (!ev.dataTransfer.types.includes(DRAG_MIME)) return;
|
||||
ev.preventDefault();
|
||||
ev.dataTransfer.dropEffect = ev.altKey ? "copy" : "move";
|
||||
setDropOver(k);
|
||||
}
|
||||
: undefined,
|
||||
onDragLeave: isDropDir ? () => setDropOver(null) : undefined,
|
||||
onDrop: isDropDir
|
||||
? (ev: React.DragEvent) => {
|
||||
ev.preventDefault();
|
||||
setDropOver(null);
|
||||
onDropToDir(e, ev);
|
||||
}
|
||||
: undefined,
|
||||
onClick: (ev: React.MouseEvent) => onRowClick(e, ev),
|
||||
onDoubleClick: () => onOpen(e),
|
||||
onContextMenu: (ev: React.MouseEvent) => {
|
||||
ev.preventDefault();
|
||||
if (!selection.keys.has(k)) onSelection({ keys: new Set([k]), anchor: k, focus: k });
|
||||
onMenu(e, { x: ev.clientX, y: ev.clientY });
|
||||
},
|
||||
onTouchStart: () => touchStart(e),
|
||||
onTouchEnd: touchEnd,
|
||||
onTouchMove: touchEnd,
|
||||
style: { position: "absolute" as const, top: i * rowH, left: 0, right: 0, height: rowH },
|
||||
className: cn(
|
||||
"cursor-default select-none border-b border-border/50 transition-colors duration-micro",
|
||||
selected ? "bg-foreground/[0.07]" : "hover:bg-foreground/[0.035]",
|
||||
focused && "ring-1 ring-ring/60 ring-inset",
|
||||
dropOver === k && "bg-foreground/[0.1] ring-2 ring-ring ring-inset",
|
||||
e.escape && "opacity-70",
|
||||
),
|
||||
};
|
||||
const menuBtn = (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${e.name} 的操作`}
|
||||
tabIndex={-1}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
const r = (ev.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
if (!selection.keys.has(k)) onSelection({ keys: new Set([k]), anchor: k, focus: k });
|
||||
onMenu(e, { x: r.right - 208, y: r.bottom + 4 });
|
||||
}}
|
||||
className="grid h-8 w-8 place-items-center rounded-full text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground"
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
if (mobile) {
|
||||
return (
|
||||
<div key={k} {...common}>
|
||||
<div className="flex h-full items-center gap-3 px-3">
|
||||
{selecting ? (
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
label={`选择 ${e.name}`}
|
||||
onToggle={() => onSelection(toggleSelect(selection, k))}
|
||||
/>
|
||||
) : (
|
||||
<EntryIcon e={e} />
|
||||
)}
|
||||
<Cell as="div" className="min-w-0 flex-1">
|
||||
<p className="truncate text-body text-foreground">{e.name}</p>
|
||||
<div className="mt-0.5 flex min-w-0 items-center gap-2 text-meta text-muted-foreground">
|
||||
<EntryStatus e={e} compact />
|
||||
<span className="shrink-0">{sizeCell(e)}</span>
|
||||
<span className="truncate">{relTime(e.mtime)}</span>
|
||||
</div>
|
||||
</Cell>
|
||||
{menuBtn}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div key={k} {...common}>
|
||||
<div className={cn("grid h-full items-center gap-3 px-3 text-sm", DESKTOP_COLS)}>
|
||||
<Cell className="grid place-items-center">
|
||||
<Checkbox
|
||||
checked={selected}
|
||||
label={`选择 ${e.name}`}
|
||||
onToggle={() => onSelection(toggleSelect(selection, k))}
|
||||
/>
|
||||
</Cell>
|
||||
<Cell className="flex min-w-0 items-center gap-2.5">
|
||||
<EntryIcon e={e} />
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate text-foreground" title={e.name}>
|
||||
{e.name}
|
||||
</span>
|
||||
{e.asset ? (
|
||||
<Link
|
||||
to={`/console/library/${e.asset.mediaEntityId}`}
|
||||
onClick={(ev) => ev.stopPropagation()}
|
||||
className="block truncate text-meta text-muted-foreground hover:text-foreground hover:underline"
|
||||
>
|
||||
{e.asset.title}
|
||||
{e.asset.year ? ` (${e.asset.year})` : ""}
|
||||
{e.asset.slotId ? " · Slot" : ""}
|
||||
</Link>
|
||||
) : null}
|
||||
</span>
|
||||
</Cell>
|
||||
<Cell className="min-w-0">
|
||||
<EntryStatus e={e} />
|
||||
</Cell>
|
||||
<Cell className="hidden font-mono text-caption text-muted-foreground xl:block">
|
||||
{e.asset?.contentUnit ?? ""}
|
||||
</Cell>
|
||||
<Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell>
|
||||
<Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell>
|
||||
<Cell className="grid place-items-center">{menuBtn}</Cell>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{loadingMore ? (
|
||||
<div className="flex items-center justify-center gap-2 py-3 text-meta text-muted-foreground">
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 加载下一页
|
||||
</div>
|
||||
) : null}
|
||||
{footer}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 网格单元:虚拟滚动的行是绝对定位的 div,不能用 <table>;单元不单独获焦(焦点在 grid 上,aria-activedescendant 指向行) */
|
||||
function Cell({
|
||||
header = false,
|
||||
as = "span",
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
header?: boolean;
|
||||
as?: "span" | "div";
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}) {
|
||||
const Tag = as;
|
||||
return (
|
||||
<Tag role={header ? "columnheader" : "gridcell"} tabIndex={-1} className={className}>
|
||||
{children}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
export const DRAG_MIME = "application/x-umt-files";
|
||||
|
||||
// 列:勾选 | 名称 | 状态 | 季集(xl)| 大小 | 修改时间(lg)| ⋯
|
||||
const DESKTOP_COLS =
|
||||
"grid-cols-[28px_minmax(0,1fr)_10.5rem_7.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_10.5rem_7.5rem_6.5rem_32px] xl:grid-cols-[28px_minmax(0,1fr)_11rem_5.5rem_7.5rem_6.5rem_32px]";
|
||||
|
||||
export function Checkbox({
|
||||
checked,
|
||||
partial = false,
|
||||
label,
|
||||
onToggle,
|
||||
}: {
|
||||
checked: boolean;
|
||||
partial?: boolean;
|
||||
label: string;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
return (
|
||||
// biome-ignore lint/a11y/useSemanticElements: 行内勾选需要阻止冒泡并保持 16px 方框外观,原生 checkbox 无法套 token 样式
|
||||
<button
|
||||
type="button"
|
||||
role="checkbox"
|
||||
aria-checked={partial ? "mixed" : checked}
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
className={cn(
|
||||
"grid h-4 w-4 place-items-center rounded-tight border transition-colors duration-micro",
|
||||
checked || partial
|
||||
? "border-foreground/70 bg-foreground/10 text-foreground"
|
||||
: "border-border hover:border-foreground/40",
|
||||
)}
|
||||
>
|
||||
{checked ? <Check className="h-3 w-3" /> : partial ? <span className="h-0.5 w-2 bg-foreground" /> : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
// 文件管理页演示数据(仅开发构建 ?mock=1):内存文件树 + 与后端同形状的读写实现,
|
||||
// 用来在后端联调前把交互走通。守卫/授权/幂等等语义以服务端为准,这里只做到“看起来对”。
|
||||
import type {
|
||||
FileDeleteCheck,
|
||||
FileEntry,
|
||||
FileIdentifyItem,
|
||||
FileKindTag,
|
||||
FileRef,
|
||||
FileRoot,
|
||||
FileStat,
|
||||
FilesListResponse,
|
||||
FilesOrganizeGroup,
|
||||
FilesTransferResult,
|
||||
TrashItem,
|
||||
} from "@umt/contracts";
|
||||
import { ApiRequestError } from "~/api/parse.ts";
|
||||
import type { FetcherArg } from "~/lib/api.ts";
|
||||
|
||||
type ItemRef = FileRef;
|
||||
type FileKind = FileKindTag;
|
||||
type FileListResponse = FilesListResponse;
|
||||
type OrganizeGroup = FilesOrganizeGroup;
|
||||
|
||||
type Node = {
|
||||
name: string;
|
||||
dir: boolean;
|
||||
size: number;
|
||||
mtime: string;
|
||||
children?: Node[];
|
||||
meta?: Partial<Pick<FileEntry, "status" | "asset" | "download" | "media" | "escape">>;
|
||||
/** 大目录:按需生成 n 个子项 */
|
||||
lazyCount?: number;
|
||||
};
|
||||
|
||||
const GB = 1024 ** 3;
|
||||
const MB = 1024 ** 2;
|
||||
const now = Date.now();
|
||||
const ago = (h: number) => new Date(now - h * 3_600_000).toISOString();
|
||||
|
||||
const f = (name: string, size: number, h: number, meta?: Node["meta"]): Node => ({
|
||||
name,
|
||||
dir: false,
|
||||
size: Math.round(size),
|
||||
mtime: ago(h),
|
||||
meta,
|
||||
});
|
||||
const d = (name: string, h: number, children: Node[], meta?: Node["meta"]): Node => ({
|
||||
name,
|
||||
dir: true,
|
||||
size: 4096,
|
||||
mtime: ago(h),
|
||||
children,
|
||||
meta,
|
||||
});
|
||||
|
||||
const dune = (cu: string | null = null) => ({
|
||||
assetId: "a-dune",
|
||||
locationId: "l-dune",
|
||||
mediaEntityId: "e-dune",
|
||||
title: "沙丘2",
|
||||
year: 2024,
|
||||
contentUnit: cu,
|
||||
slotId: "s-dune-uhd",
|
||||
slotStatus: "satisfied" as const,
|
||||
isPrimary: true,
|
||||
});
|
||||
const tlou = (ep: number) => ({
|
||||
assetId: `a-tlou-${ep}`,
|
||||
locationId: `l-tlou-${ep}`,
|
||||
mediaEntityId: "e-tlou",
|
||||
title: "最后生还者",
|
||||
year: 2023,
|
||||
contentUnit: `S02E${String(ep).padStart(2, "0")}`,
|
||||
slotId: "s-tlou-uhd",
|
||||
slotStatus: "satisfied" as const,
|
||||
isPrimary: true,
|
||||
});
|
||||
const uhdInfo: FileEntry["media"] = {
|
||||
resolution: "2160p",
|
||||
hdr: "DV/HDR10",
|
||||
videoCodec: "HEVC",
|
||||
audioTracks: 3,
|
||||
durationSec: 9960,
|
||||
};
|
||||
|
||||
function makeRoots(): { roots: FileRoot[]; trees: Record<string, Node> } {
|
||||
const roots: FileRoot[] = [
|
||||
{
|
||||
id: "lib:movies",
|
||||
kind: "library",
|
||||
name: "电影库",
|
||||
hostPath: "/mnt/media/movies",
|
||||
mediaKind: "movie",
|
||||
writable: true,
|
||||
health: "healthy",
|
||||
checkedAt: ago(0.01),
|
||||
healthReason: null,
|
||||
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
|
||||
},
|
||||
{
|
||||
id: "lib:tv",
|
||||
kind: "library",
|
||||
name: "剧集库",
|
||||
hostPath: "/mnt/media/tv",
|
||||
mediaKind: "series",
|
||||
writable: true,
|
||||
health: "healthy",
|
||||
checkedAt: ago(0.01),
|
||||
healthReason: null,
|
||||
usage: { totalBytes: 16_000 * GB, freeBytes: 3_420 * GB },
|
||||
},
|
||||
{
|
||||
id: "lib:nas-old",
|
||||
kind: "library",
|
||||
name: "旧 NAS(只读登记)",
|
||||
hostPath: "/mnt/nas-old",
|
||||
mediaKind: "mixed",
|
||||
writable: false,
|
||||
health: "offline",
|
||||
checkedAt: ago(3),
|
||||
healthReason: "挂载点无响应(ENOTCONN)",
|
||||
usage: null,
|
||||
},
|
||||
{
|
||||
id: "dl:qb-main:0",
|
||||
kind: "download",
|
||||
name: "qBittorrent · 下载",
|
||||
hostPath: "/srv/downloads",
|
||||
mediaKind: null,
|
||||
writable: false,
|
||||
health: "healthy",
|
||||
checkedAt: ago(0.01),
|
||||
healthReason: null,
|
||||
usage: { totalBytes: 8_000 * GB, freeBytes: 1_210 * GB },
|
||||
},
|
||||
];
|
||||
const tlouEps = [1, 2, 3, 4, 5, 6, 7].map((ep) =>
|
||||
f(
|
||||
`The Last of Us - S02E${String(ep).padStart(2, "0")} - 2160p WEB-DL.mkv`,
|
||||
7.4 * GB + ep * 91 * MB,
|
||||
400 - ep * 24,
|
||||
{
|
||||
status: "registered",
|
||||
asset: tlou(ep),
|
||||
media: uhdInfo,
|
||||
},
|
||||
),
|
||||
);
|
||||
const trees: Record<string, Node> = {
|
||||
"lib:movies": d("", 1, [
|
||||
d("Dune Part Two (2024)", 30, [
|
||||
f("Dune Part Two (2024) - 2160p UHD BluRay.mkv", 71.2 * GB, 30, {
|
||||
status: "registered",
|
||||
asset: dune(),
|
||||
media: uhdInfo,
|
||||
}),
|
||||
f("Dune Part Two (2024) - 2160p UHD BluRay.zh.srt", 96_000, 30, { status: "registered" }),
|
||||
f("movie.nfo", 4_300, 30),
|
||||
f("poster.jpg", 820_000, 30),
|
||||
]),
|
||||
d("Oppenheimer (2023)", 240, [
|
||||
f("Oppenheimer (2023) - 1080p.mkv", 18.4 * GB, 240, {
|
||||
status: "registered",
|
||||
asset: {
|
||||
...dune(),
|
||||
assetId: "a-opp",
|
||||
mediaEntityId: "e-opp",
|
||||
title: "奥本海默",
|
||||
year: 2023,
|
||||
slotStatus: "upgrade_pending" as const,
|
||||
},
|
||||
}),
|
||||
]),
|
||||
d("_未归类", 12, [f("sample.mkv", 42 * MB, 12, { status: "unidentified" })]),
|
||||
f(".DS_Store", 6_148, 900),
|
||||
]),
|
||||
"lib:tv": d("", 2, [
|
||||
d("The Last of Us (2023)", 24, [
|
||||
d("Season 01", 900, []),
|
||||
d("Season 02", 24, tlouEps),
|
||||
f("tvshow.nfo", 5_120, 24),
|
||||
]),
|
||||
d("Severance (2022)", 120, [d("Season 01", 1200, []), d("Season 02", 120, [])]),
|
||||
]),
|
||||
"dl:qb-main:0": d("", 0.5, [
|
||||
d(
|
||||
"Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR",
|
||||
30,
|
||||
[
|
||||
f("Dune.Part.Two.2024.2160p.UHD.BluRay.REMUX.DV.HDR.HEVC.TrueHD.7.1.Atmos-FraMeSToR.mkv", 71.2 * GB, 30, {
|
||||
status: "seeding",
|
||||
download: { taskId: "t-dune", status: "completed", progress: 1, hnr: "unsatisfied" },
|
||||
}),
|
||||
],
|
||||
{ status: "seeding", download: { taskId: "t-dune", status: "completed", progress: 1, hnr: "unsatisfied" } },
|
||||
),
|
||||
d(
|
||||
"The.Last.of.Us.S02.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX",
|
||||
20,
|
||||
[1, 2, 3, 4, 5, 6, 7].map((ep) =>
|
||||
f(
|
||||
`The.Last.of.Us.S02E${String(ep).padStart(2, "0")}.2160p.MAX.WEB-DL.DDP5.1.Atmos.DV.HDR.H.265-FLUX.mkv`,
|
||||
7.4 * GB,
|
||||
20,
|
||||
{
|
||||
status: "seeding",
|
||||
download: { taskId: "t-tlou", status: "completed", progress: 1, hnr: "satisfied" },
|
||||
},
|
||||
),
|
||||
),
|
||||
{ status: "seeding", download: { taskId: "t-tlou", status: "completed", progress: 1, hnr: "satisfied" } },
|
||||
),
|
||||
d("散文件", 3, [
|
||||
f("Severance.S02E01.Hello.Ms.Cobel.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.1 * GB, 3, {
|
||||
status: "unidentified",
|
||||
}),
|
||||
f("Severance.S02E02.Goodbye.Mrs.Selvig.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.3 * GB, 3, {
|
||||
status: "unidentified",
|
||||
}),
|
||||
f("Severance.S02E03.Who.Is.Alive.1080p.ATVP.WEB-DL.DDP5.1.H.264-NTb.mkv", 3.0 * GB, 3, {
|
||||
status: "unidentified",
|
||||
}),
|
||||
f("Severance.S02E01.zh.ass", 88_000, 3),
|
||||
]),
|
||||
f("Oppenheimer.2023.2160p.BluRay.x265.10bit.HDR-GRP.mkv", 31.8 * GB, 50, { status: "unidentified" }),
|
||||
f("Nosferatu.2024.2160p.WEB-DL.mkv", 14.2 * GB, 0.2, {
|
||||
status: "downloading",
|
||||
download: { taskId: "t-nos", status: "downloading", progress: 0.42, hnr: "unknown" },
|
||||
}),
|
||||
f("Nosferatu.2024.2160p.WEB-DL.mkv.!qB", 2.1 * GB, 0.1, { status: "temp" }),
|
||||
f("README.txt", 1_024, 700),
|
||||
f("escape-link", 0, 700, { escape: true }),
|
||||
{ name: "大量样片", dir: true, size: 4096, mtime: ago(5), children: [], lazyCount: 25_000 },
|
||||
f(".hidden-note", 64, 700),
|
||||
]),
|
||||
};
|
||||
return { roots, trees };
|
||||
}
|
||||
|
||||
let state = makeRoots();
|
||||
let trash: TrashItem[] = [
|
||||
{
|
||||
id: "tr-1",
|
||||
rootId: "dl:qb-main:0",
|
||||
originalPath: "Old.Movie.2019.1080p.mkv",
|
||||
trashPath: "/srv/downloads/.umt-trash/20260926/1/Old.Movie.2019.1080p.mkv",
|
||||
isDir: false,
|
||||
size: Math.round(9.2 * GB),
|
||||
deletedAt: ago(30),
|
||||
expiresAt: new Date(now + 5.7 * 86_400_000).toISOString(),
|
||||
actor: "w31-admin",
|
||||
assetSnapshot: null,
|
||||
},
|
||||
{
|
||||
id: "tr-2",
|
||||
rootId: "lib:movies",
|
||||
originalPath: "Tenet (2020)",
|
||||
trashPath: "/mnt/media/movies/.umt-trash/20260921/2/Tenet (2020)",
|
||||
isDir: true,
|
||||
size: 22 * GB,
|
||||
deletedAt: ago(150),
|
||||
expiresAt: new Date(now + 0.8 * 86_400_000).toISOString(),
|
||||
actor: "w31-admin",
|
||||
assetSnapshot: { title: "信条", contentUnit: null },
|
||||
},
|
||||
];
|
||||
|
||||
export function resetMock() {
|
||||
state = makeRoots();
|
||||
}
|
||||
|
||||
const VIDEO = new Set(["mkv", "mp4", "m2ts", "ts", "avi", "mov", "webm"]);
|
||||
const SUB = new Set(["srt", "ass", "ssa", "sup", "vtt"]);
|
||||
const IMG = new Set(["jpg", "jpeg", "png", "webp"]);
|
||||
|
||||
function kindOf(name: string, dir: boolean): { ext: string | null; fileKind: FileKind } {
|
||||
if (dir) return { ext: null, fileKind: "other" };
|
||||
const m = /\.([A-Za-z0-9]{1,6})$/.exec(name);
|
||||
const ext = m ? (m[1] as string).toLowerCase() : null;
|
||||
if (!ext) return { ext, fileKind: "other" };
|
||||
if (VIDEO.has(ext)) return { ext, fileKind: "video" };
|
||||
if (SUB.has(ext)) return { ext, fileKind: "subtitle" };
|
||||
if (IMG.has(ext)) return { ext, fileKind: "image" };
|
||||
if (ext === "nfo") return { ext, fileKind: "nfo" };
|
||||
if (["flac", "mka", "ac3", "dts"].includes(ext)) return { ext, fileKind: "audio" };
|
||||
return { ext, fileKind: "other" };
|
||||
}
|
||||
|
||||
function lazyChildren(n: Node): Node[] {
|
||||
if (!n.lazyCount) return n.children ?? [];
|
||||
if (n.children && n.children.length >= Math.min(n.lazyCount, 20_000)) return n.children;
|
||||
const kids: Node[] = [];
|
||||
for (let i = 1; i <= Math.min(n.lazyCount, 20_000); i++) {
|
||||
kids.push(
|
||||
f(`sample-${String(i).padStart(5, "0")}.mkv`, 30 * MB + (i % 97) * MB, 5 + (i % 300), { status: "unidentified" }),
|
||||
);
|
||||
}
|
||||
n.children = kids;
|
||||
return kids;
|
||||
}
|
||||
|
||||
function rootOf(root: string): FileRoot {
|
||||
const r = state.roots.find((x) => x.id === root);
|
||||
if (!r) throw new ApiRequestError("not_found", "not_found: 根不存在或未登记");
|
||||
if (r.health === "offline")
|
||||
throw new ApiRequestError("upstream_unavailable", `root_unavailable: ${r.healthReason ?? "挂载点无响应"}`);
|
||||
return r;
|
||||
}
|
||||
|
||||
function find(root: string, path: string): { node: Node; parent: Node | null } {
|
||||
rootOf(root);
|
||||
let node = state.trees[root];
|
||||
if (!node) throw new ApiRequestError("not_found", "not_found: 根不存在");
|
||||
let parent: Node | null = null;
|
||||
for (const seg of path.split("/").filter(Boolean)) {
|
||||
if (seg === "..") throw new ApiRequestError("forbidden", "path_escape: 路径越界");
|
||||
const next = lazyChildren(node).find((c) => c.name === seg);
|
||||
if (!next) throw new ApiRequestError("not_found", `not_found: 不存在 ${path}`);
|
||||
parent = node;
|
||||
node = next;
|
||||
}
|
||||
return { node, parent };
|
||||
}
|
||||
|
||||
const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
||||
|
||||
function toEntry(n: Node, path: string): FileEntry {
|
||||
const { ext, fileKind } = kindOf(n.name, n.dir);
|
||||
let status = n.meta?.status ?? "plain";
|
||||
if (!n.meta?.status && fileKind === "video") status = "unidentified";
|
||||
if (/\.(!qB|part|uhd-partial)$/.test(n.name)) status = "temp";
|
||||
let dirSummary: FileEntry["dirSummary"] = null;
|
||||
if (n.dir) {
|
||||
const kids = n.lazyCount ? [] : (n.children ?? []);
|
||||
const vids = kids.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video");
|
||||
dirSummary = n.lazyCount
|
||||
? { videoCount: 500, registeredCount: 0, partial: true }
|
||||
: {
|
||||
videoCount: vids.length,
|
||||
registeredCount: vids.filter((v) => v.meta?.status === "registered").length,
|
||||
partial: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
name: n.name,
|
||||
path,
|
||||
type: n.meta?.escape ? "symlink" : n.dir ? "dir" : "file",
|
||||
size: n.dir ? 0 : n.size,
|
||||
mtime: n.mtime,
|
||||
ext,
|
||||
fileKind: n.dir ? null : fileKind,
|
||||
status,
|
||||
escape: !!n.meta?.escape,
|
||||
asset: n.meta?.asset ?? null,
|
||||
download: n.meta?.download ?? null,
|
||||
media: n.meta?.media ?? null,
|
||||
dirSummary,
|
||||
};
|
||||
}
|
||||
|
||||
const STATUS_RANK: Record<string, number> = {
|
||||
temp: 0,
|
||||
downloading: 1,
|
||||
registered: 2,
|
||||
seeding: 3,
|
||||
unidentified: 4,
|
||||
plain: 5,
|
||||
};
|
||||
|
||||
function globToRegex(q: string): RegExp {
|
||||
if (!/[*?]/.test(q)) return new RegExp(q.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i");
|
||||
const src = q
|
||||
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
||||
.replace(/\*/g, ".*")
|
||||
.replace(/\?/g, ".");
|
||||
return new RegExp(`^${src}$`, "i");
|
||||
}
|
||||
|
||||
function list(params: Record<string, unknown>): FileListResponse {
|
||||
const root = String(params.root ?? "");
|
||||
const path = String(params.path ?? "");
|
||||
const { node } = find(root, path);
|
||||
if (!node.dir) throw new ApiRequestError("bad_request", "不是目录");
|
||||
let kids = lazyChildren(node);
|
||||
const truncated = (node.lazyCount ?? 0) > 20_000;
|
||||
if (params.hidden !== "true") kids = kids.filter((k) => !k.name.startsWith("."));
|
||||
let entries = kids.map((k) => toEntry(k, join(path, k.name)));
|
||||
const q = String(params.q ?? "").trim();
|
||||
if (q) {
|
||||
const re = globToRegex(q);
|
||||
entries = entries.filter((e) => re.test(e.name));
|
||||
}
|
||||
const filters = String(params.filter ?? "")
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
if (filters.length) {
|
||||
entries = entries.filter((e) =>
|
||||
filters.some((fl) =>
|
||||
fl === "video"
|
||||
? e.fileKind === "video"
|
||||
: fl === "subtitle"
|
||||
? e.fileKind === "subtitle"
|
||||
: fl === "unregistered"
|
||||
? e.status !== "registered"
|
||||
: e.status === fl,
|
||||
),
|
||||
);
|
||||
}
|
||||
const sort = String(params.sort ?? "name");
|
||||
const dir = params.order === "desc" ? -1 : 1;
|
||||
const coll = new Intl.Collator("zh-CN", { numeric: true, sensitivity: "base" });
|
||||
entries.sort((a, b) => {
|
||||
const da = a.type === "dir" ? 0 : 1;
|
||||
const db = b.type === "dir" ? 0 : 1;
|
||||
if (da !== db) return da - db;
|
||||
let c = 0;
|
||||
if (sort === "size") c = a.size - b.size;
|
||||
else if (sort === "mtime") c = (a.mtime ?? "").localeCompare(b.mtime ?? "");
|
||||
else if (sort === "status") c = (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9);
|
||||
else c = coll.compare(a.name, b.name);
|
||||
return (c || coll.compare(a.name, b.name)) * dir;
|
||||
});
|
||||
const offset = Number(params.cursor ?? 0);
|
||||
const limit = Number(params.limit ?? 200);
|
||||
return {
|
||||
root,
|
||||
path,
|
||||
entries: entries.slice(offset, offset + limit),
|
||||
nextCursor: offset + limit < entries.length ? String(offset + limit) : null,
|
||||
total: entries.length,
|
||||
truncated,
|
||||
stale: false,
|
||||
scannedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
function bindingsFor(root: string, path: string, node: Node) {
|
||||
return node.dir && root.startsWith("lib:") && path && !path.includes("/")
|
||||
? [
|
||||
{
|
||||
bindingId: `b-${node.name}`,
|
||||
kind: "entity" as const,
|
||||
mediaEntityId: "e-x",
|
||||
title: node.name.replace(/ \(\d{4}\)$/, ""),
|
||||
seasonNumber: null,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
}
|
||||
|
||||
function stat(params: Record<string, unknown>): FileStat {
|
||||
const root = String(params.root ?? "");
|
||||
const path = String(params.path ?? "");
|
||||
const r = rootOf(root);
|
||||
const { node, parent } = find(root, path);
|
||||
const entry = toEntry(node, path);
|
||||
const stem = node.name.replace(/\.[^.]+$/, "");
|
||||
const dirPath = path.split("/").slice(0, -1).join("/");
|
||||
const sidecars = node.dir
|
||||
? []
|
||||
: (parent?.children ?? [])
|
||||
.filter((c) => c !== node && c.name.startsWith(`${stem}.`))
|
||||
.map((c) => join(dirPath, c.name));
|
||||
return {
|
||||
entry,
|
||||
realPath: `${r.hostPath}/${path}`,
|
||||
inode: 1_000_000 + path.length * 97,
|
||||
device: 2051,
|
||||
nlink: entry.status === "seeding" ? 2 : 1,
|
||||
sharedInode: entry.status === "seeding",
|
||||
sidecars,
|
||||
bindings: bindingsFor(root, path, node),
|
||||
};
|
||||
}
|
||||
|
||||
function detach(ref: ItemRef): Node {
|
||||
const { node, parent } = find(ref.root, ref.path);
|
||||
if (!parent?.children) throw new ApiRequestError("forbidden", "不能操作根本身");
|
||||
parent.children = parent.children.filter((c) => c !== node);
|
||||
return node;
|
||||
}
|
||||
|
||||
const clone = (n: Node): Node => ({ ...n, mtime: new Date().toISOString(), children: n.children?.map(clone) });
|
||||
|
||||
function walk(n: Node): Node[] {
|
||||
return [n, ...(n.children ?? []).flatMap(walk)];
|
||||
}
|
||||
|
||||
function deleteChecks(items: ItemRef[]): FileDeleteCheck[] {
|
||||
return items.map((it) => {
|
||||
const { node } = find(it.root, it.path);
|
||||
const all = walk(node);
|
||||
const dls = all
|
||||
.filter((n) => n.meta?.download && (n.meta.status === "downloading" || n.meta.download.hnr !== "satisfied"))
|
||||
.map((n) => n.meta?.download)
|
||||
.filter((x): x is NonNullable<typeof x> => !!x)
|
||||
.map((x) => ({ taskId: x.taskId, status: x.status, hnr: x.hnr }));
|
||||
const downloads = [...new Map(dls.map((x) => [x.taskId, x])).values()];
|
||||
const assets = all
|
||||
.filter((n) => n.meta?.asset)
|
||||
.map((n) => ({
|
||||
assetId: n.meta?.asset?.assetId ?? "",
|
||||
locationId: n.meta?.asset?.locationId ?? "",
|
||||
mediaEntityId: n.meta?.asset?.mediaEntityId ?? "",
|
||||
relativePath: n.name,
|
||||
}));
|
||||
const bindings = bindingsFor(it.root, it.path, node);
|
||||
return {
|
||||
root: it.root,
|
||||
path: it.path,
|
||||
isDir: node.dir,
|
||||
size: all.reduce((s, n) => s + (n.dir ? 0 : n.size), 0),
|
||||
blocked: !it.path
|
||||
? "is_root"
|
||||
: downloads.length
|
||||
? "download_active"
|
||||
: assets.length
|
||||
? "needs_retire"
|
||||
: bindings.length
|
||||
? "needs_unbind"
|
||||
: null,
|
||||
downloads,
|
||||
assets,
|
||||
bindings,
|
||||
sharedInode: node.meta?.status === "seeding",
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseName(name: string) {
|
||||
const base = name.replace(/\.[^.]+$/, "");
|
||||
const se = /S(\d{1,2})E(\d{1,3})(?:-?E(\d{1,3}))?/i.exec(base);
|
||||
const yr = /[.( ](19\d{2}|20\d{2})[.) ]/.exec(`${base} `);
|
||||
const res = /(2160p|1080p|720p)/i.exec(base);
|
||||
const cut = Math.min(...[se?.index, yr?.index, res?.index].filter((x): x is number => x != null), base.length);
|
||||
const title = base
|
||||
.slice(0, cut)
|
||||
.replace(/[._]+/g, " ")
|
||||
.replace(/[-( ]+$/, "")
|
||||
.trim();
|
||||
const grp = /-([A-Za-z0-9]+)$/.exec(base);
|
||||
const eps: number[] = [];
|
||||
if (se) {
|
||||
const a = Number(se[2]);
|
||||
const b = se[3] ? Number(se[3]) : a;
|
||||
for (let i = a; i <= b; i++) eps.push(i);
|
||||
}
|
||||
return {
|
||||
title: title || null,
|
||||
year: yr ? Number(yr[1]) : null,
|
||||
season: se ? Number(se[1]) : null,
|
||||
episodes: eps,
|
||||
resolution: res ? (res[1] as string).toLowerCase() : null,
|
||||
source: /bluray/i.test(base) ? "BluRay" : /web-?dl/i.test(base) ? "WEB-DL" : null,
|
||||
releaseGroup: grp ? (grp[1] as string) : null,
|
||||
mediaKind: se ? ("series" as const) : ("movie" as const),
|
||||
};
|
||||
}
|
||||
|
||||
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
|
||||
{ match: /severance/i, id: "e-sev", title: "人生切割术", year: 2022, kind: "series" },
|
||||
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
|
||||
{ match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" },
|
||||
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
|
||||
];
|
||||
|
||||
/** 本地作品搜索(向导里改选作品) */
|
||||
export function mockEntitySearch(q: string) {
|
||||
const t = q.trim().toLowerCase();
|
||||
return KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({
|
||||
id: k.id,
|
||||
title: k.title,
|
||||
year: k.year,
|
||||
kind: k.kind,
|
||||
}));
|
||||
}
|
||||
|
||||
function identify(items: ItemRef[]): { items: FileIdentifyItem[]; truncated: boolean } {
|
||||
const out: FileIdentifyItem[] = [];
|
||||
for (const it of items) {
|
||||
const { node } = find(it.root, it.path);
|
||||
const files = node.dir
|
||||
? lazyChildren(node)
|
||||
.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video")
|
||||
.slice(0, 200)
|
||||
.map((c) => ({ path: join(it.path, c.name), name: c.name }))
|
||||
: [{ path: it.path, name: node.name }];
|
||||
for (const fl of files) {
|
||||
const parsed = parseName(fl.name);
|
||||
const hit = KNOWN.find((k) => k.match.test(parsed.title ?? ""));
|
||||
const candidates = hit
|
||||
? [
|
||||
{
|
||||
mediaEntityId: hit.id,
|
||||
provider: "local",
|
||||
externalId: null,
|
||||
title: hit.title,
|
||||
year: hit.year,
|
||||
kind: hit.kind,
|
||||
level: "high" as const,
|
||||
score: 0.94,
|
||||
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
|
||||
},
|
||||
{
|
||||
mediaEntityId: null,
|
||||
provider: "tmdb",
|
||||
externalId: "99999",
|
||||
title: `${parsed.title}(同名旧作)`,
|
||||
year: (parsed.year ?? hit.year) - 30,
|
||||
kind: hit.kind,
|
||||
level: "low" as const,
|
||||
score: 0.31,
|
||||
reasons: ["仅标题相似"],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
out.push({
|
||||
root: it.root,
|
||||
path: fl.path,
|
||||
parsed,
|
||||
candidates,
|
||||
suggestion: { mediaEntityId: hit?.id ?? null, contentUnitId: null, contentUnit: null },
|
||||
existing: { assetId: null },
|
||||
warnings: hit ? [] : ["本地与 TMDB 都没有找到可信候选"],
|
||||
});
|
||||
}
|
||||
}
|
||||
return { items: out, truncated: false };
|
||||
}
|
||||
|
||||
let seq = 0;
|
||||
|
||||
function doMove(body: {
|
||||
items: ItemRef[];
|
||||
target: ItemRef;
|
||||
mode: "move" | "copy";
|
||||
dryRun: boolean;
|
||||
planId?: string;
|
||||
}): FilesTransferResult {
|
||||
const tRoot = rootOf(body.target.root);
|
||||
const target = find(body.target.root, body.target.path).node;
|
||||
if (!target.dir || !tRoot.writable)
|
||||
throw new ApiRequestError("bad_request", "not_writable: 目标必须是可写库根下的目录");
|
||||
const pairs = body.items.map((it) => {
|
||||
const { node } = find(it.root, it.path);
|
||||
const name = node.name;
|
||||
const exists = (target.children ?? []).some((c) => c.name === name);
|
||||
const inside =
|
||||
it.root === body.target.root &&
|
||||
(`${body.target.path}/`.startsWith(`${it.path}/`) || it.path === body.target.path);
|
||||
return {
|
||||
source: it,
|
||||
target: { root: body.target.root, path: join(body.target.path, name) },
|
||||
size: walk(node).reduce((s, n) => s + (n.dir ? 0 : n.size), 0),
|
||||
conflict: exists || inside,
|
||||
registered: walk(node).some((n) => !!n.meta?.asset),
|
||||
};
|
||||
});
|
||||
const hnrBlocks =
|
||||
body.mode === "move"
|
||||
? body.items.flatMap((it) =>
|
||||
walk(find(it.root, it.path).node)
|
||||
.filter((n) => n.meta?.download && n.meta.download.hnr !== "satisfied")
|
||||
.map((n) => ({
|
||||
taskId: n.meta?.download?.taskId ?? "",
|
||||
path: it.path,
|
||||
hnr: n.meta?.download?.hnr ?? "unknown",
|
||||
status: n.meta?.download?.status ?? "completed",
|
||||
})),
|
||||
)
|
||||
: [];
|
||||
const totalBytes = pairs.reduce((s, p) => s + p.size, 0);
|
||||
const res: FilesTransferResult = {
|
||||
dryRun: body.dryRun,
|
||||
planId: body.planId ?? `plan-${++seq}`,
|
||||
planExpiresAt: new Date(Date.now() + 15 * 60_000).toISOString(),
|
||||
mode: body.mode,
|
||||
pairs,
|
||||
totalBytes,
|
||||
freeBytes: tRoot.usage?.freeBytes ?? null,
|
||||
spaceOk: true,
|
||||
hnrBlocks,
|
||||
truncated: false,
|
||||
executed: null,
|
||||
};
|
||||
if (body.dryRun) return res;
|
||||
if (hnrBlocks.length) throw new ApiRequestError("conflict", "download_active: 含 H&R 未满足的做种文件,不能 Move");
|
||||
const succeeded: string[] = [];
|
||||
const failed: { path: string; code: string; message: string }[] = [];
|
||||
for (const p of pairs) {
|
||||
if (p.conflict) {
|
||||
failed.push({ path: p.source.path, code: "exists", message: "目标已存在同名项,不覆盖" });
|
||||
continue;
|
||||
}
|
||||
const n = body.mode === "move" ? detach(p.source) : clone(find(p.source.root, p.source.path).node);
|
||||
target.children = [...(target.children ?? []), n];
|
||||
succeeded.push(p.source.path);
|
||||
}
|
||||
return { ...res, executed: { succeeded, failed, recordId: `rec-${++seq}` } };
|
||||
}
|
||||
|
||||
function organize(body: {
|
||||
items: { root: string; path: string; mediaEntityId: string; season?: number; episodes?: number[] }[];
|
||||
action: "copy" | "move";
|
||||
dryRun: boolean;
|
||||
}): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } {
|
||||
const byEntity = new Map<string, typeof body.items>();
|
||||
for (const it of body.items) byEntity.set(it.mediaEntityId, [...(byEntity.get(it.mediaEntityId) ?? []), it]);
|
||||
const groups: OrganizeGroup[] = [];
|
||||
for (const [id, items] of byEntity) {
|
||||
const k = KNOWN.find((x) => x.id === id);
|
||||
const tv = k?.kind === "series";
|
||||
const libRoot = tv ? "/mnt/media/tv" : "/mnt/media/movies";
|
||||
const dirName = id === "e-tlou" ? "The Last of Us (2023)" : `${k?.title ?? id} (${k?.year ?? ""})`;
|
||||
const files = items.map((it) => {
|
||||
const ext = it.path.split(".").pop();
|
||||
const s = it.season != null ? `S${String(it.season).padStart(2, "0")}` : "";
|
||||
const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : "";
|
||||
const name = tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
|
||||
const dst = tv
|
||||
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
|
||||
: `${libRoot}/${dirName}/${name}`;
|
||||
const r = state.roots.find((x) => x.id === it.root);
|
||||
const { node } = find(it.root, it.path);
|
||||
return { source: `${r?.hostPath}/${it.path}`, target: dst, kind: "main", size: node.size };
|
||||
});
|
||||
groups.push({
|
||||
mediaEntityId: id,
|
||||
contentUnitId: null,
|
||||
planId: `oplan-${id}`,
|
||||
files,
|
||||
truncated: false,
|
||||
recordId: body.dryRun ? null : `rec-${++seq}`,
|
||||
taskId: body.dryRun ? null : `task-${seq}`,
|
||||
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
return {
|
||||
dryRun: body.dryRun,
|
||||
groups,
|
||||
recordIds: groups.map((g) => g.recordId).filter((x): x is string => !!x),
|
||||
};
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/** 与后端同路径同形状的内存实现 */
|
||||
export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown> {
|
||||
await delay(120 + Math.random() * 160);
|
||||
const p = (arg.params ?? {}) as Record<string, unknown>;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: mock 请求体按端点解释
|
||||
const b = (arg.body ?? {}) as any;
|
||||
switch (path) {
|
||||
case "/files/roots":
|
||||
return { items: state.roots };
|
||||
case "/files":
|
||||
return list(p);
|
||||
case "/files/stat":
|
||||
return stat(p);
|
||||
case "/files/mkdir": {
|
||||
const { node } = find(b.root, b.path);
|
||||
if ((node.children ?? []).some((c) => c.name === b.name))
|
||||
throw new ApiRequestError("conflict", "exists: 同名目录已存在");
|
||||
node.children = [...(node.children ?? []), d(b.name, 0, [])];
|
||||
return { root: b.root, path: join(b.path, b.name) };
|
||||
}
|
||||
case "/files/rename": {
|
||||
const { node, parent } = find(b.root, b.path);
|
||||
if (node.meta?.download)
|
||||
throw new ApiRequestError("conflict", "download_active: 做种中,改名会让下载器找不到文件");
|
||||
if ((parent?.children ?? []).some((c) => c.name === b.newName && c !== node))
|
||||
throw new ApiRequestError("conflict", "exists: 目标名已存在,不覆盖");
|
||||
const stem = node.name.replace(/\.[^.]+$/, "");
|
||||
const newStem = String(b.newName).replace(/\.[^.]+$/, "");
|
||||
const dirPath = String(b.path).split("/").slice(0, -1).join("/");
|
||||
const side =
|
||||
node.dir || !b.withSidecars
|
||||
? []
|
||||
: (parent?.children ?? []).filter((c) => c !== node && c.name.startsWith(`${stem}.`));
|
||||
const renames = [
|
||||
{ from: b.path, to: join(dirPath, b.newName), role: "main" as const },
|
||||
...side.map((c) => ({
|
||||
from: join(dirPath, c.name),
|
||||
to: join(dirPath, newStem + c.name.slice(stem.length)),
|
||||
role: "sidecar" as const,
|
||||
})),
|
||||
];
|
||||
const assetLocations =
|
||||
walk(node).filter((n) => n.meta?.asset).length + side.filter((c) => c.meta?.status === "registered").length;
|
||||
const bindings = bindingsFor(b.root, b.path, node).map((x) => ({
|
||||
oldBindingId: x.bindingId,
|
||||
newBindingId: null,
|
||||
}));
|
||||
if (!b.dryRun) {
|
||||
node.name = b.newName;
|
||||
for (const c of side) c.name = newStem + c.name.slice(stem.length);
|
||||
}
|
||||
return { dryRun: !!b.dryRun, renames, assetLocations, bindings, recordId: b.dryRun ? null : `rec-${++seq}` };
|
||||
}
|
||||
case "/files/move":
|
||||
return doMove(b);
|
||||
case "/files/delete": {
|
||||
const checks = deleteChecks(b.items).map((c) => ({
|
||||
...c,
|
||||
blocked:
|
||||
c.blocked === "needs_retire" && b.retireAssets
|
||||
? c.bindings.length && !b.unbind
|
||||
? ("needs_unbind" as const)
|
||||
: null
|
||||
: c.blocked === "needs_unbind" && b.unbind
|
||||
? null
|
||||
: c.blocked,
|
||||
}));
|
||||
const base = {
|
||||
dryRun: !!b.dryRun,
|
||||
planId: b.dryRun ? `dplan-${++seq}` : (b.planId ?? null),
|
||||
planExpiresAt: null,
|
||||
mode: b.mode ?? "trash",
|
||||
checks,
|
||||
trashed: [],
|
||||
deleted: [],
|
||||
failed: [],
|
||||
recordId: null,
|
||||
};
|
||||
if (b.dryRun) return base;
|
||||
if (checks.some((c) => c.blocked)) throw new ApiRequestError("conflict", "download_active: 有被引用项,不能删除");
|
||||
const trashed: TrashItem[] = [];
|
||||
for (const it of b.items as ItemRef[]) {
|
||||
const n = detach(it);
|
||||
const r = rootOf(it.root);
|
||||
if (b.mode !== "permanent") {
|
||||
const t: TrashItem = {
|
||||
id: `tr-${++seq}`,
|
||||
rootId: it.root,
|
||||
originalPath: it.path,
|
||||
trashPath: `${r.hostPath}/.umt-trash/20260927/${seq}/${it.path}`,
|
||||
isDir: n.dir,
|
||||
size: walk(n).reduce((s, x) => s + (x.dir ? 0 : x.size), 0),
|
||||
deletedAt: new Date().toISOString(),
|
||||
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
||||
actor: "w31-admin",
|
||||
assetSnapshot: n.meta?.asset ? { title: n.meta.asset.title, contentUnit: n.meta.asset.contentUnit } : null,
|
||||
};
|
||||
trashed.push(t);
|
||||
trash.unshift(t);
|
||||
}
|
||||
}
|
||||
return { ...base, trashed, deleted: (b.items as ItemRef[]).map((x) => x.path), recordId: `rec-${++seq}` };
|
||||
}
|
||||
case "/files/trash":
|
||||
return { items: trash, nextCursor: null };
|
||||
case "/files/trash/restore": {
|
||||
const restored: { id: string; root: string; path: string }[] = [];
|
||||
const failed: { id: string; code: string; message: string }[] = [];
|
||||
for (const t of trash.filter((x) => b.ids.includes(x.id))) {
|
||||
const name = t.originalPath.split("/").pop() ?? t.originalPath;
|
||||
const parentPath = b.target ? b.target.path : t.originalPath.split("/").slice(0, -1).join("/");
|
||||
const rootId = b.target ? b.target.root : t.rootId;
|
||||
try {
|
||||
const { node } = find(rootId, parentPath);
|
||||
if ((node.children ?? []).some((c) => c.name === name)) {
|
||||
failed.push({ id: t.id, code: "exists", message: `原位置已被占用:${t.originalPath}` });
|
||||
continue;
|
||||
}
|
||||
node.children = [...(node.children ?? []), t.isDir ? d(name, 1, []) : f(name, t.size, 1)];
|
||||
restored.push({ id: t.id, root: rootId, path: join(parentPath, name) });
|
||||
} catch (e) {
|
||||
failed.push({ id: t.id, code: "not_found", message: e instanceof Error ? e.message : "还原失败" });
|
||||
}
|
||||
}
|
||||
const ok = new Set(restored.map((r) => r.id));
|
||||
trash = trash.filter((x) => !ok.has(x.id));
|
||||
return { restored, failed };
|
||||
}
|
||||
case "/files/trash/purge": {
|
||||
const ids: string[] = b.ids ?? trash.filter((x) => Date.parse(x.expiresAt) <= Date.now()).map((x) => x.id);
|
||||
trash = trash.filter((x) => !ids.includes(x.id));
|
||||
return { purged: ids, failed: [] };
|
||||
}
|
||||
case "/files/identify":
|
||||
await delay(500);
|
||||
return identify(b.items);
|
||||
case "/files/organize":
|
||||
await delay(400);
|
||||
return organize(b);
|
||||
case "/files/scrape":
|
||||
return {
|
||||
mediaEntityId: b.mediaEntityId,
|
||||
taskId: `run-${++seq}`,
|
||||
lockedFields: ["title"],
|
||||
nfo: b.writeNfo ? "not_implemented" : "not_requested",
|
||||
images: b.images ? "not_implemented" : "not_requested",
|
||||
};
|
||||
case "/files/probe":
|
||||
await delay(600);
|
||||
return {
|
||||
items: (b.items as ItemRef[]).map((it) => ({
|
||||
...it,
|
||||
status: "ok",
|
||||
reason: null,
|
||||
summary: uhdInfo,
|
||||
container: "matroska",
|
||||
subtitleTracks: 4,
|
||||
savedToAsset: false,
|
||||
cached: true,
|
||||
})),
|
||||
};
|
||||
default:
|
||||
throw new ApiRequestError("not_found", `mock 未实现:${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function mockPreview(root: string, path: string): Promise<string> {
|
||||
await delay(100);
|
||||
if (path.endsWith(".nfo"))
|
||||
return `<?xml version="1.0" encoding="utf-8"?>\n<!-- umt -->\n<movie>\n <title>沙丘2</title>\n <year>2024</year>\n</movie>`;
|
||||
if (/\.(srt|ass)$/.test(path))
|
||||
return "1\n00:00:01,000 --> 00:00:04,000\n厄拉科斯……\n\n2\n00:00:05,000 --> 00:00:07,500\n一切都从这里开始。";
|
||||
return `(${root}:${path})`;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFilesMock } from "~/api/files.ts";
|
||||
import {
|
||||
clickSelect,
|
||||
collapseCrumbs,
|
||||
deleteBlockText,
|
||||
emptySelection,
|
||||
entryStatusView,
|
||||
formatEpisodes,
|
||||
moveFocus,
|
||||
nameProblem,
|
||||
opDisabledReason,
|
||||
parseEpisodes,
|
||||
pruneSelection,
|
||||
selectAll,
|
||||
shiftEpisodes,
|
||||
splitCommon,
|
||||
stemRange,
|
||||
suggestName,
|
||||
} from "./model.ts";
|
||||
import { rowProblem, rowsFromIdentify, setRowEntity, type WizardRow } from "./organize-wizard.tsx";
|
||||
|
||||
const order = ["a", "b", "c", "d", "e"];
|
||||
|
||||
describe("选择模型", () => {
|
||||
it("单击单选,Ctrl 切换,Shift 区间", () => {
|
||||
let s = clickSelect(emptySelection(), order, "b", {});
|
||||
expect([...s.keys]).toEqual(["b"]);
|
||||
s = clickSelect(s, order, "d", { shift: true });
|
||||
expect([...s.keys]).toEqual(["b", "c", "d"]);
|
||||
s = clickSelect(s, order, "a", { shift: true });
|
||||
expect([...s.keys]).toEqual(["a", "b"]);
|
||||
s = clickSelect(s, order, "e", { ctrl: true });
|
||||
expect([...s.keys].sort()).toEqual(["a", "b", "e"]);
|
||||
s = clickSelect(s, order, "e", { ctrl: true });
|
||||
expect([...s.keys].sort()).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("Shift+Ctrl 区间并入已有选择", () => {
|
||||
let s = clickSelect(emptySelection(), order, "a", {});
|
||||
s = clickSelect(s, order, "c", { ctrl: true });
|
||||
s = clickSelect(s, order, "e", { shift: true, ctrl: true });
|
||||
expect([...s.keys].sort()).toEqual(["a", "c", "d", "e"]);
|
||||
});
|
||||
|
||||
it("方向键移动焦点,Shift 扩选,边界夹住", () => {
|
||||
let s = moveFocus(emptySelection(), order, 1, false);
|
||||
expect(s.focus).toBe("a");
|
||||
s = moveFocus({ ...s, anchor: "a", keys: new Set(["a"]) }, order, 2, true);
|
||||
expect([...s.keys]).toEqual(["a", "b", "c"]);
|
||||
s = moveFocus(s, order, 99, false);
|
||||
expect(s.focus).toBe("e");
|
||||
s = moveFocus(s, order, -99, false);
|
||||
expect(s.focus).toBe("a");
|
||||
});
|
||||
|
||||
it("全选与刷新后剪枝", () => {
|
||||
const s = selectAll(order, emptySelection());
|
||||
expect(s.keys.size).toBe(5);
|
||||
const p = pruneSelection({ ...s, focus: "e", anchor: "z" }, ["a", "e"]);
|
||||
expect([...p.keys]).toEqual(["a", "e"]);
|
||||
expect(p.anchor).toBeNull();
|
||||
expect(p.focus).toBe("e");
|
||||
});
|
||||
});
|
||||
|
||||
const base = {
|
||||
name: "x.mkv",
|
||||
path: "x.mkv",
|
||||
type: "file" as const,
|
||||
size: 1,
|
||||
mtime: null,
|
||||
ext: "mkv",
|
||||
fileKind: "video" as const,
|
||||
status: "plain" as const,
|
||||
escape: false,
|
||||
asset: null,
|
||||
download: null,
|
||||
media: null,
|
||||
dirSummary: null,
|
||||
};
|
||||
|
||||
describe("状态与禁用原因", () => {
|
||||
it("状态徽标带文字,H&R 未满足为 warning,逃逸为 destructive", () => {
|
||||
expect(entryStatusView({ ...base, status: "registered" })?.label).toBe("已登记");
|
||||
expect(
|
||||
entryStatusView({
|
||||
...base,
|
||||
status: "seeding",
|
||||
download: { taskId: "t", status: "completed", progress: 1, hnr: "unsatisfied" },
|
||||
}),
|
||||
).toMatchObject({ tone: "warning" });
|
||||
expect(
|
||||
entryStatusView({
|
||||
...base,
|
||||
status: "downloading",
|
||||
download: { taskId: "t", status: "downloading", progress: 0.425, hnr: "unknown" },
|
||||
})?.label,
|
||||
).toBe("下载中 42%");
|
||||
expect(entryStatusView({ ...base, escape: true })?.tone).toBe("destructive");
|
||||
expect(entryStatusView(base)).toBeNull();
|
||||
});
|
||||
|
||||
it("做种中不能改名/删除;只读登记根不能移动;未登记不能刮削", () => {
|
||||
const lib = { kind: "library" as const, writable: true };
|
||||
const seeding = {
|
||||
...base,
|
||||
status: "seeding" as const,
|
||||
download: { taskId: "t", status: "completed" as const, progress: 1, hnr: "unsatisfied" as const },
|
||||
};
|
||||
expect(opDisabledReason("rename", seeding, lib)).toMatch(/做种/);
|
||||
expect(opDisabledReason("delete", seeding, lib)).toMatch(/下载任务/);
|
||||
expect(opDisabledReason("move", seeding, lib)).toMatch(/H&R/);
|
||||
expect(opDisabledReason("move", base, { kind: "library", writable: false })).toMatch(/只读/);
|
||||
expect(opDisabledReason("rename", base, { kind: "download", writable: false })).toBeNull();
|
||||
expect(opDisabledReason("scrape", base, lib)).toMatch(/未登记/);
|
||||
expect(opDisabledReason("copyPath", { ...base, escape: true }, lib)).toBeNull();
|
||||
expect(opDisabledReason("rename", { ...base, escape: true }, lib)).toMatch(/根之外/);
|
||||
});
|
||||
|
||||
it("删除阻断分软硬", () => {
|
||||
expect(deleteBlockText("needs_retire")?.soft).toBe(true);
|
||||
expect(deleteBlockText("download_active")?.soft).toBe(false);
|
||||
expect(deleteBlockText(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("路径与名称", () => {
|
||||
it("面包屑折叠保留根与末两段", () => {
|
||||
expect(collapseCrumbs([1, 2, 3, 4, 5], 3)).toEqual([1, null, 4, 5]);
|
||||
expect(collapseCrumbs([1, 2], 3)).toEqual([1, 2]);
|
||||
});
|
||||
it("名称校验与改名默认选中主名", () => {
|
||||
expect(nameProblem("a/b")).toBeTruthy();
|
||||
expect(nameProblem("..")).toBeTruthy();
|
||||
expect(nameProblem(" ok.mkv ")).toBeNull();
|
||||
expect(stemRange("Movie (2024).mkv", false)).toEqual([0, 12]);
|
||||
expect(stemRange(".hidden", false)).toEqual([0, 7]);
|
||||
expect(stemRange("dir.name", true)).toEqual([0, 8]);
|
||||
});
|
||||
it("前后路径按段取公共前缀", () => {
|
||||
expect(splitCommon("/a/b/c.mkv", "/a/b/d.mkv")).toEqual({ common: "/a/b/", a: "c.mkv", b: "d.mkv" });
|
||||
expect(splitCommon("/x/1.mkv", "/y/1.mkv").common).toBe("/");
|
||||
});
|
||||
it("集号解析/格式化/偏移", () => {
|
||||
expect(parseEpisodes("3-5")).toEqual([3, 4, 5]);
|
||||
expect(parseEpisodes("3,7")).toEqual([3, 7]);
|
||||
expect(parseEpisodes("x")).toBeNull();
|
||||
expect(parseEpisodes("")).toEqual([]);
|
||||
expect(formatEpisodes([5, 3, 4])).toBe("3-5");
|
||||
expect(formatEpisodes([1, 3])).toBe("1,3");
|
||||
expect(shiftEpisodes([1, 2], -1)).toEqual([1, 1]);
|
||||
expect(shiftEpisodes([1, 2], 12)).toEqual([13, 14]);
|
||||
});
|
||||
it("建议名:电影与剧集", () => {
|
||||
expect(
|
||||
suggestName({ title: "沙丘2", year: 2024, kind: "movie" }, { season: null, episodes: [] }, "a.mkv", false),
|
||||
).toBe("沙丘2 (2024).mkv");
|
||||
expect(
|
||||
suggestName({ title: "人生切割术", year: 2022, kind: "series" }, { season: 2, episodes: [1] }, "a.mkv", false),
|
||||
).toBe("人生切割术 (2022) - S02E01.mkv");
|
||||
expect(suggestName({ title: "A/B", year: null, kind: "movie" }, { season: null, episodes: [] }, "d", true)).toBe(
|
||||
"A B",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const item = (path: string, over: Partial<{ id: string | null; level: "high" | "low" }> = {}) => ({
|
||||
root: "lib:x",
|
||||
path,
|
||||
parsed: {
|
||||
title: "T",
|
||||
year: null,
|
||||
season: 1,
|
||||
episodes: [2],
|
||||
resolution: null,
|
||||
source: null,
|
||||
releaseGroup: null,
|
||||
mediaKind: "series" as const,
|
||||
},
|
||||
candidates:
|
||||
over.id === null
|
||||
? []
|
||||
: [
|
||||
{
|
||||
mediaEntityId: over.id ?? "e1",
|
||||
provider: "local",
|
||||
externalId: null,
|
||||
title: "T",
|
||||
year: 2020,
|
||||
kind: "series" as const,
|
||||
level: over.level ?? ("high" as const),
|
||||
score: 1,
|
||||
reasons: [],
|
||||
},
|
||||
],
|
||||
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
|
||||
existing: { assetId: null },
|
||||
warnings: [],
|
||||
});
|
||||
|
||||
describe("整理向导门禁", () => {
|
||||
it("高置信自动选中;低置信/无候选为未决,排除后放行", () => {
|
||||
const rows = rowsFromIdentify(
|
||||
[item("d/a.mkv"), item("d/b.mkv", { level: "low" }), item("e/c.mkv", { id: null })],
|
||||
new Set(),
|
||||
);
|
||||
expect(rows[0]?.entity?.id).toBe("e1");
|
||||
expect(rows[0]?.episodes).toBe("2");
|
||||
expect(rowProblem(rows[0] as WizardRow)).toBeNull();
|
||||
expect(rowProblem(rows[1] as WizardRow)).toMatch(/作品/);
|
||||
expect(rowProblem({ ...(rows[2] as WizardRow), excluded: true })).toBeNull();
|
||||
});
|
||||
|
||||
it("改作品时同目录未手动改过的行跟随,别的目录不动", () => {
|
||||
let rows = rowsFromIdentify(
|
||||
[item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null }), item("e/c.mkv", { id: null })],
|
||||
new Set(),
|
||||
);
|
||||
const e = { id: "e9", title: "X", year: 2001, kind: "series" };
|
||||
rows = setRowEntity(rows, rows[0]?.key as string, e);
|
||||
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e9", "e9", null]);
|
||||
rows = setRowEntity(rows, rows[1]?.key as string, { ...e, id: "e8" });
|
||||
rows = setRowEntity(rows, rows[0]?.key as string, { ...e, id: "e7" });
|
||||
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e7", "e8", null]);
|
||||
});
|
||||
|
||||
it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => {
|
||||
const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"]));
|
||||
expect(rows[0]?.hnrBlocked).toBe(true);
|
||||
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
|
||||
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("演示开关", () => {
|
||||
it("只在开发构建生效,?mock=1 记住,?mock=0 清掉", () => {
|
||||
const store = new Map<string, string>();
|
||||
const storage = {
|
||||
getItem: (k: string) => store.get(k) ?? null,
|
||||
setItem: (k: string, v: string) => void store.set(k, v),
|
||||
removeItem: (k: string) => void store.delete(k),
|
||||
};
|
||||
expect(resolveFilesMock({ dev: false, search: "?mock=1", storage })).toBe(false);
|
||||
expect(resolveFilesMock({ dev: true, search: "?mock=1", storage })).toBe(true);
|
||||
expect(resolveFilesMock({ dev: true, search: "", storage })).toBe(true);
|
||||
expect(resolveFilesMock({ dev: true, search: "?mock=0", storage })).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
|
||||
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
|
||||
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
||||
|
||||
// ─── 状态徽标 ────────────────────────────────────────────────────
|
||||
|
||||
export type StatusView = { label: string; tone: StatusTone; hint?: string };
|
||||
|
||||
/** 行主状态 → 徽标(文字必带,不只靠颜色) */
|
||||
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
|
||||
if (e.escape) return { label: "链接逃逸", tone: "destructive", hint: "符号链接指向登记根之外,已禁用所有操作" };
|
||||
const s: FileStatus = e.status;
|
||||
switch (s) {
|
||||
case "temp":
|
||||
return { label: "临时文件", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
|
||||
case "downloading":
|
||||
return {
|
||||
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
|
||||
tone: "neutral",
|
||||
};
|
||||
case "registered":
|
||||
return { label: "已登记", tone: "success" };
|
||||
case "seeding":
|
||||
return e.download?.hnr === "unsatisfied"
|
||||
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 未满足前不能改名、删除或 Move" }
|
||||
: { label: "做种中", tone: "neutral" };
|
||||
case "unidentified":
|
||||
return { label: "待识别", tone: "warning" };
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const FILTER_CHIPS = [
|
||||
{ value: "", label: "全部" },
|
||||
{ value: "video", label: "视频" },
|
||||
{ value: "unidentified", label: "待识别" },
|
||||
{ value: "registered", label: "已登记" },
|
||||
{ value: "downloading", label: "下载中" },
|
||||
{ value: "temp", label: "临时文件" },
|
||||
] as const;
|
||||
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: "name", label: "名称" },
|
||||
{ value: "mtime", label: "修改时间" },
|
||||
{ value: "size", label: "大小" },
|
||||
{ value: "status", label: "状态" },
|
||||
] as const;
|
||||
|
||||
// ─── 禁用原因(菜单 tooltip 用) ───────────────────────────────────
|
||||
|
||||
export type FileOp =
|
||||
| "open"
|
||||
| "rename"
|
||||
| "move"
|
||||
| "copy"
|
||||
| "organize"
|
||||
| "identify"
|
||||
| "scrape"
|
||||
| "probe"
|
||||
| "delete"
|
||||
| "copyPath"
|
||||
| "openMedia"
|
||||
| "records";
|
||||
|
||||
/** 返回 null = 可用;否则为禁用原因 */
|
||||
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
|
||||
if (e.escape && op !== "copyPath") return "链接指向登记根之外,已禁用";
|
||||
// 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项)
|
||||
const rootWritable = root.writable || root.kind === "download";
|
||||
const seeding = e.status === "seeding" || e.status === "downloading";
|
||||
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
|
||||
switch (op) {
|
||||
case "rename":
|
||||
if (!rootWritable) return "只读根,不能改名";
|
||||
if (seeding) return "做种/下载中,改名会让下载器找不到文件";
|
||||
if (e.status === "temp") return "临时文件不能改名";
|
||||
return null;
|
||||
case "move":
|
||||
if (!rootWritable) return "只读登记根,不能移出(可用“复制到…”)";
|
||||
if (hnrBlocked) return "做种中且 H&R 未满足,不能 Move";
|
||||
return null;
|
||||
case "delete":
|
||||
if (!rootWritable) return "只读根,不能删除";
|
||||
if (seeding) return "纳管下载任务仍在使用,不能删除";
|
||||
return null;
|
||||
case "organize":
|
||||
case "identify":
|
||||
if (e.status === "temp") return "临时文件不能整理";
|
||||
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录可识别/整理";
|
||||
return null;
|
||||
case "scrape":
|
||||
return e.asset ? null : "未登记,请先识别并整理入库";
|
||||
case "probe":
|
||||
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
|
||||
case "openMedia":
|
||||
return e.asset ? null : "未关联作品";
|
||||
case "records":
|
||||
return e.asset || e.status === "registered" ? null : "没有整理记录";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 选择模型(Shift 区间 / Ctrl 切换 / 全选) ─────────────────────
|
||||
|
||||
export type Selection = { keys: Set<string>; anchor: string | null; focus: string | null };
|
||||
|
||||
export const emptySelection = (): Selection => ({ keys: new Set(), anchor: null, focus: null });
|
||||
|
||||
/** 点击一行:plain 单选;ctrl 切换;shift 从锚点到当前的区间(与 ctrl 同按则并入) */
|
||||
export function clickSelect(
|
||||
sel: Selection,
|
||||
order: string[],
|
||||
key: string,
|
||||
mod: { shift?: boolean; ctrl?: boolean },
|
||||
): Selection {
|
||||
if (mod.shift && sel.anchor && order.includes(sel.anchor)) {
|
||||
const a = order.indexOf(sel.anchor);
|
||||
const b = order.indexOf(key);
|
||||
const [lo, hi] = a < b ? [a, b] : [b, a];
|
||||
const range = order.slice(lo, hi + 1);
|
||||
const keys = mod.ctrl ? new Set([...sel.keys, ...range]) : new Set(range);
|
||||
return { keys, anchor: sel.anchor, focus: key };
|
||||
}
|
||||
if (mod.ctrl) {
|
||||
const keys = new Set(sel.keys);
|
||||
if (keys.has(key)) keys.delete(key);
|
||||
else keys.add(key);
|
||||
return { keys, anchor: key, focus: key };
|
||||
}
|
||||
return { keys: new Set([key]), anchor: key, focus: key };
|
||||
}
|
||||
|
||||
/** Space:切换焦点行的勾选 */
|
||||
export function toggleSelect(sel: Selection, key: string): Selection {
|
||||
const keys = new Set(sel.keys);
|
||||
if (keys.has(key)) keys.delete(key);
|
||||
else keys.add(key);
|
||||
return { keys, anchor: key, focus: key };
|
||||
}
|
||||
|
||||
export function selectAll(order: string[], sel: Selection): Selection {
|
||||
return { keys: new Set(order), anchor: sel.anchor ?? order[0] ?? null, focus: sel.focus ?? order[0] ?? null };
|
||||
}
|
||||
|
||||
/** ↑↓ 移动焦点;shift 时从锚点扩选 */
|
||||
export function moveFocus(sel: Selection, order: string[], delta: number, shift: boolean): Selection {
|
||||
if (order.length === 0) return sel;
|
||||
const cur = sel.focus ? order.indexOf(sel.focus) : -1;
|
||||
const next = Math.max(0, Math.min(order.length - 1, cur < 0 ? (delta > 0 ? 0 : order.length - 1) : cur + delta));
|
||||
const key = order[next] as string;
|
||||
if (shift) return clickSelect(sel.anchor ? sel : { ...sel, anchor: sel.focus ?? key }, order, key, { shift: true });
|
||||
return { ...sel, focus: key };
|
||||
}
|
||||
|
||||
/** 列表刷新后丢掉已不存在的键 */
|
||||
export function pruneSelection(sel: Selection, order: string[]): Selection {
|
||||
const present = new Set(order);
|
||||
const keys = new Set([...sel.keys].filter((k) => present.has(k)));
|
||||
return {
|
||||
keys,
|
||||
anchor: sel.anchor && present.has(sel.anchor) ? sel.anchor : null,
|
||||
focus: sel.focus && present.has(sel.focus) ? sel.focus : null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── 路径工具(根内相对路径,"" = 根) ─────────────────────────────
|
||||
|
||||
export function joinRel(dir: string, name: string): string {
|
||||
return dir ? `${dir}/${name}` : name;
|
||||
}
|
||||
|
||||
export function parentRel(path: string): string {
|
||||
const i = path.lastIndexOf("/");
|
||||
return i < 0 ? "" : path.slice(0, i);
|
||||
}
|
||||
|
||||
export function baseName(path: string): string {
|
||||
const i = path.lastIndexOf("/");
|
||||
return i < 0 ? path : path.slice(i + 1);
|
||||
}
|
||||
|
||||
export function segments(path: string): string[] {
|
||||
return path ? path.split("/").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
/** 面包屑折叠:超过 max 段时保留首段(根)+ … + 最后 max-1 段 */
|
||||
export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
|
||||
if (items.length <= max) return items;
|
||||
return [items[0] as T, null, ...items.slice(items.length - (max - 1))];
|
||||
}
|
||||
|
||||
/** 名称校验(前端先挡一遍,服务端 sanitizeSegment 才是准绳) */
|
||||
export function nameProblem(name: string): string | null {
|
||||
const n = name.trim();
|
||||
if (!n) return "名称不能为空";
|
||||
if (n === "." || n === "..") return "不能用 . 或 ..";
|
||||
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
|
||||
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
|
||||
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
|
||||
if (n.length > 255) return "名称过长";
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 改名时默认只选中主名(不含扩展名) */
|
||||
export function stemRange(name: string, isDir: boolean): [number, number] {
|
||||
if (isDir) return [0, name.length];
|
||||
const i = name.lastIndexOf(".");
|
||||
return [0, i > 0 ? i : name.length];
|
||||
}
|
||||
|
||||
/** 选中项合计大小(目录不计) */
|
||||
export function totalSize(entries: FileEntry[]): number {
|
||||
return entries.reduce((s, e) => s + (e.type === "file" ? e.size : 0), 0);
|
||||
}
|
||||
|
||||
export function newIdempotencyKey(): string {
|
||||
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
||||
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
/** 集号偏移:episodes 全体加 n(不小于 1) */
|
||||
export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined {
|
||||
if (!eps?.length) return eps;
|
||||
return eps.map((e) => Math.max(1, e + n));
|
||||
}
|
||||
|
||||
/** "3" / "3-5" / "3,4" → [3] / [3,4,5] / [3,4];非法返回 null */
|
||||
export function parseEpisodes(text: string): number[] | null {
|
||||
const t = text.trim();
|
||||
if (!t) return [];
|
||||
const out: number[] = [];
|
||||
for (const part of t.split(/[,\s]+/)) {
|
||||
const m = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(part);
|
||||
if (!m) return null;
|
||||
const a = Number(m[1]);
|
||||
const b = m[2] ? Number(m[2]) : a;
|
||||
if (b < a || b - a > 200) return null;
|
||||
for (let i = a; i <= b; i++) out.push(i);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function formatEpisodes(eps: number[] | undefined | null): string {
|
||||
if (!eps?.length) return "";
|
||||
const sorted = [...eps].sort((a, b) => a - b);
|
||||
const first = sorted[0] as number;
|
||||
const last = sorted[sorted.length - 1] as number;
|
||||
if (sorted.length > 1 && last - first === sorted.length - 1) return `${first}-${last}`;
|
||||
return sorted.join(",");
|
||||
}
|
||||
|
||||
export function contentUnitLabel(season?: number | null, episodes?: number[] | null): string {
|
||||
if (season == null) return "";
|
||||
const s = `S${String(season).padStart(2, "0")}`;
|
||||
if (!episodes?.length) return s;
|
||||
const sorted = [...episodes].sort((a, b) => a - b);
|
||||
const e = (n: number) => `E${String(n).padStart(2, "0")}`;
|
||||
return sorted.length > 1
|
||||
? `${s}${e(sorted[0] as number)}-${e(sorted[sorted.length - 1] as number)}`
|
||||
: `${s}${e(sorted[0] as number)}`;
|
||||
}
|
||||
|
||||
/** 前后路径对照:按 / 段取公共前缀,差异部分单独高亮 */
|
||||
export function splitCommon(a: string, b: string): { common: string; a: string; b: string } {
|
||||
const sa = a.split("/");
|
||||
const sb = b.split("/");
|
||||
let i = 0;
|
||||
while (i < sa.length - 1 && i < sb.length - 1 && sa[i] === sb[i]) i++;
|
||||
const common = i ? `${sa.slice(0, i).join("/")}/` : "";
|
||||
return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") };
|
||||
}
|
||||
|
||||
/** 识别建议名:电影 "标题 (年).ext",剧集 "标题 - S01E02.ext" */
|
||||
export function suggestName(
|
||||
cand: { title: string; year: number | null; kind: string },
|
||||
parsed: { season: number | null; episodes: number[] },
|
||||
currentName: string,
|
||||
isDir: boolean,
|
||||
): string {
|
||||
const ext = isDir ? "" : (/\.[A-Za-z0-9]{1,6}$/.exec(currentName)?.[0] ?? "");
|
||||
const base = `${cand.title}${cand.year ? ` (${cand.year})` : ""}`.replace(/[/\\:*?"<>|]/g, " ").trim();
|
||||
if (isDir) return base;
|
||||
if (cand.kind !== "movie" && parsed.season != null)
|
||||
return `${base} - ${contentUnitLabel(parsed.season, parsed.episodes)}${ext}`;
|
||||
return `${base}${ext}`;
|
||||
}
|
||||
|
||||
/** 删除阻断原因文案;可通过勾选解除的返回 soft=true */
|
||||
export function deleteBlockText(b: string | null): { text: string; soft: boolean } | null {
|
||||
switch (b) {
|
||||
case null:
|
||||
return null;
|
||||
case "download_active":
|
||||
return { text: "纳管下载任务仍在做种或 H&R 未满足", soft: false };
|
||||
case "is_root":
|
||||
return { text: "不能删除根本身", soft: false };
|
||||
case "needs_retire":
|
||||
return { text: "含已登记资产,需勾选“同时退役资产”", soft: true };
|
||||
case "needs_unbind":
|
||||
return { text: "是作品/季的绑定目录,需勾选“解除绑定”", soft: true };
|
||||
case "root_unavailable":
|
||||
return { text: "根离线,不能确认文件状态", soft: false };
|
||||
case "not_found":
|
||||
return { text: "文件已不存在", soft: false };
|
||||
case "escape":
|
||||
return { text: "链接指向根外", soft: false };
|
||||
default:
|
||||
return { text: b, soft: false };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,856 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Check,
|
||||
ChevronDown,
|
||||
FileStack,
|
||||
LoaderCircle,
|
||||
Search,
|
||||
TriangleAlert,
|
||||
Undo2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileIdentifyCandidate,
|
||||
type FileIdentifyItem,
|
||||
type FileRoot,
|
||||
type FilesOrganizeGroup,
|
||||
filesErrorText,
|
||||
filesMockEnabled,
|
||||
useIdentify,
|
||||
useOrganizeFiles,
|
||||
} from "~/api/files.ts";
|
||||
import { useEntitySearch } from "~/api/records.ts";
|
||||
import { useSlots } from "~/api/slots.ts";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { Button } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { PathPair } from "./dialogs.tsx";
|
||||
import {
|
||||
baseName,
|
||||
contentUnitLabel,
|
||||
formatEpisodes,
|
||||
newIdempotencyKey,
|
||||
parentRel,
|
||||
parseEpisodes,
|
||||
shiftEpisodes,
|
||||
} from "./model.ts";
|
||||
|
||||
type Entity = { id: string; title: string; year: number | null; kind: string };
|
||||
|
||||
export type WizardRow = {
|
||||
key: string;
|
||||
root: string;
|
||||
path: string;
|
||||
item: FileIdentifyItem;
|
||||
entity: Entity | null;
|
||||
/** 用户手动改过作品(同组联动时不覆盖) */
|
||||
touched: boolean;
|
||||
season: string;
|
||||
episodes: string;
|
||||
slotId: string;
|
||||
excluded: boolean;
|
||||
/** 同目录分组键 */
|
||||
group: string;
|
||||
hnrBlocked: boolean;
|
||||
};
|
||||
|
||||
const LEVEL: Record<string, { label: string; tone: "success" | "warning" | "neutral" }> = {
|
||||
high: { label: "高", tone: "success" },
|
||||
medium: { label: "中", tone: "neutral" },
|
||||
low: { label: "低", tone: "warning" },
|
||||
};
|
||||
|
||||
const STEPS = ["识别", "选项", "预览", "执行"] as const;
|
||||
|
||||
/** 行是否仍未决(必须处理或排除才能下一步) */
|
||||
export function rowProblem(r: WizardRow): string | null {
|
||||
if (r.excluded) return null;
|
||||
if (!r.entity) return "未选择作品";
|
||||
if (parseEpisodes(r.episodes) === null) return "集号格式不对(如 3、3-5、3,4)";
|
||||
if (r.season && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
|
||||
if (r.entity.kind !== "movie" && !r.season.trim()) return "剧集需要季号";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string>, rootKey = ""): WizardRow[] {
|
||||
return items.map((it) => {
|
||||
const top = it.candidates[0];
|
||||
const auto =
|
||||
top?.mediaEntityId && (top.level === "high" || it.suggestion.mediaEntityId === top.mediaEntityId)
|
||||
? { id: top.mediaEntityId, title: top.title, year: top.year, kind: top.kind }
|
||||
: null;
|
||||
return {
|
||||
key: `${it.root}\u0000${it.path}${rootKey}`,
|
||||
root: it.root,
|
||||
path: it.path,
|
||||
item: it,
|
||||
entity: auto,
|
||||
touched: false,
|
||||
season: it.parsed.season != null ? String(it.parsed.season) : "",
|
||||
episodes: formatEpisodes(it.parsed.episodes),
|
||||
slotId: "",
|
||||
excluded: false,
|
||||
group: `${it.root}\u0000${parentRel(it.path)}`,
|
||||
hnrBlocked: [...hnrPaths].some((p) => it.path === p || it.path.startsWith(`${p}/`)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 改一行的作品:同组中未手动改过的行跟随 */
|
||||
export function setRowEntity(rows: WizardRow[], key: string, entity: Entity | null, propagate = true): WizardRow[] {
|
||||
const target = rows.find((r) => r.key === key);
|
||||
if (!target) return rows;
|
||||
return rows.map((r) => {
|
||||
if (r.key === key) return { ...r, entity, touched: true };
|
||||
if (propagate && r.group === target.group && !r.touched && !r.excluded) return { ...r, entity };
|
||||
return r;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量整理向导(全高 Sheet,4 步):识别 → 选项 → dryRun 预览 → 执行。
|
||||
* 逐项可改作品 / 季 / 集 / Slot / 排除;按作品分组预览,单组失败不影响其他组。
|
||||
*/
|
||||
export function OrganizeWizard({
|
||||
roots,
|
||||
entries,
|
||||
rootId,
|
||||
onClose,
|
||||
}: {
|
||||
roots: FileRoot[];
|
||||
entries: FileEntry[];
|
||||
rootId: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const [step, setStep] = useState(0);
|
||||
const [rows, setRows] = useState<WizardRow[]>([]);
|
||||
const [action, setAction] = useState<"copy" | "move">("copy");
|
||||
const [targetRoot, setTargetRoot] = useState<string>("");
|
||||
const [preview, setPreview] = useState<FilesOrganizeGroup[] | null>(null);
|
||||
const [result, setResult] = useState<{ groups: FilesOrganizeGroup[]; recordIds: string[] } | null>(null);
|
||||
const [offset, setOffset] = useState("1");
|
||||
const identify = useIdentify();
|
||||
const dry = useOrganizeFiles();
|
||||
const exec = useOrganizeFiles();
|
||||
const execKey = useMemo(() => newIdempotencyKey(), []);
|
||||
const refs = useMemo(() => entries.map((e) => ({ root: rootId, path: e.path })), [entries, rootId]);
|
||||
const hnrPaths = useMemo(
|
||||
() =>
|
||||
new Set(
|
||||
entries
|
||||
.filter((e) => e.download && (e.download.hnr !== "satisfied" || e.status === "downloading"))
|
||||
.map((e) => e.path),
|
||||
),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const identifyMutate = identify.mutate;
|
||||
useEffect(() => {
|
||||
identifyMutate(
|
||||
{ items: refs },
|
||||
{
|
||||
onSuccess: (r) => setRows(rowsFromIdentify(r.items, hnrPaths)),
|
||||
onError: (e) => toast.error(filesErrorText(e, "识别失败")),
|
||||
},
|
||||
);
|
||||
}, [identifyMutate, refs, hnrPaths]);
|
||||
|
||||
const active = rows.filter((r) => !r.excluded);
|
||||
const problems = rows.filter((r) => rowProblem(r));
|
||||
const anyHnr = active.some((r) => r.hnrBlocked);
|
||||
const entityKinds = new Set(active.map((r) => r.entity?.kind).filter(Boolean));
|
||||
const libRoots = roots.filter(
|
||||
(r) =>
|
||||
r.kind === "library" &&
|
||||
r.writable &&
|
||||
r.health !== "offline" &&
|
||||
(r.mediaKind === "mixed" ||
|
||||
r.mediaKind === "other" ||
|
||||
[...entityKinds].every((k) => (k === "movie" ? r.mediaKind === "movie" : r.mediaKind !== "movie"))),
|
||||
);
|
||||
const entityTitle = new Map(active.filter((r) => r.entity).map((r) => [r.entity?.id, r.entity as Entity]));
|
||||
|
||||
const body = (dryRun: boolean, planIds?: string[]) => ({
|
||||
idempotencyKey: dryRun ? `${execKey}-dry-${Date.now()}` : execKey,
|
||||
action,
|
||||
targetLibraryRootId: targetRoot ? targetRoot.replace(/^lib:/, "") : undefined,
|
||||
dryRun,
|
||||
planIds,
|
||||
items: active.map((r) => ({
|
||||
root: r.root,
|
||||
path: r.path,
|
||||
mediaEntityId: r.entity?.id ?? "",
|
||||
season: r.season.trim() ? Number(r.season) : undefined,
|
||||
episodes: parseEpisodes(r.episodes)?.length ? (parseEpisodes(r.episodes) ?? undefined) : undefined,
|
||||
slotId: r.slotId || undefined,
|
||||
})),
|
||||
});
|
||||
|
||||
const runPreview = () => {
|
||||
setPreview(null);
|
||||
setStep(2);
|
||||
dry.mutate(body(true), {
|
||||
onSuccess: (r) => setPreview(r.groups),
|
||||
onError: (e) => toast.error(filesErrorText(e, "预览失败")),
|
||||
});
|
||||
};
|
||||
|
||||
const okGroups = preview?.filter((g) => g.planId && !g.error) ?? [];
|
||||
const runExec = () => {
|
||||
setStep(3);
|
||||
exec.mutate(
|
||||
body(
|
||||
false,
|
||||
okGroups.map((g) => g.planId as string),
|
||||
),
|
||||
{
|
||||
onSuccess: (r) => setResult({ groups: r.groups, recordIds: r.recordIds }),
|
||||
onError: (e) => {
|
||||
toast.error(filesErrorText(e, "执行失败"));
|
||||
setStep(2);
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const update = (key: string, patch: Partial<WizardRow>) =>
|
||||
setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
|
||||
const applyOffset = () => {
|
||||
const n = Number(offset);
|
||||
if (!Number.isFinite(n) || !n) return;
|
||||
setRows((rs) =>
|
||||
rs.map((r) => {
|
||||
if (r.excluded) return r;
|
||||
const eps = parseEpisodes(r.episodes);
|
||||
return eps?.length ? { ...r, episodes: formatEpisodes(shiftEpisodes(eps, n)) } : r;
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const recordsHref =
|
||||
result && result.recordIds.length === 1
|
||||
? `/console/organize/${result.recordIds[0]}`
|
||||
: `/console/organize?ids=${result?.recordIds.join(",") ?? ""}`;
|
||||
|
||||
return (
|
||||
<Sheet open onOpenChange={(o) => !o && onClose()}>
|
||||
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-5xl">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
|
||||
<SheetTitle className="text-lead">整理向导</SheetTitle>
|
||||
<SheetDescription>
|
||||
{entries.length === 1 ? entries[0]?.name : `${entries.length} 项`} ·
|
||||
确认后才落库;与自动整理产出同格式的整理记录。
|
||||
</SheetDescription>
|
||||
<ol className="mt-3 flex flex-wrap items-center gap-1.5" aria-label="步骤">
|
||||
{STEPS.map((s, i) => (
|
||||
<li key={s} className="flex items-center gap-1.5">
|
||||
{i > 0 ? <span aria-hidden className="h-px w-4 bg-border" /> : null}
|
||||
<span
|
||||
aria-current={step === i ? "step" : undefined}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-meta",
|
||||
step === i
|
||||
? "pill-selected text-foreground"
|
||||
: i < step
|
||||
? "border-border text-foreground"
|
||||
: "border-border text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{i < step ? <Check className="h-3 w-3" /> : <span className="tabular-nums">{i + 1}</span>}
|
||||
{s}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
|
||||
{step === 0 ? (
|
||||
identify.isPending ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 识别中:解析文件名并匹配本地作品与 TMDB…
|
||||
</p>
|
||||
) : identify.error ? (
|
||||
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2 text-caption">
|
||||
<StatusBadge>{rows.length} 个视频</StatusBadge>
|
||||
{problems.length ? (
|
||||
<StatusBadge tone="warning">{problems.length} 项未决:选作品或排除</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success">全部已确认</StatusBadge>
|
||||
)}
|
||||
{identify.data?.truncated ? (
|
||||
<StatusBadge tone="warning">目录内视频过多,只取前 200 个</StatusBadge>
|
||||
) : null}
|
||||
<span className="ml-auto flex items-center gap-1.5">
|
||||
集号偏移
|
||||
<Input
|
||||
value={offset}
|
||||
onChange={(e) => setOffset(e.target.value)}
|
||||
className="h-8 w-16 text-center"
|
||||
aria-label="集号偏移量"
|
||||
/>
|
||||
<Button size="sm" variant="outline" onClick={applyOffset}>
|
||||
应用到全部
|
||||
</Button>
|
||||
</span>
|
||||
</div>
|
||||
<ul className="space-y-2">
|
||||
{rows.map((r) => (
|
||||
<IdentifyRow
|
||||
key={r.key}
|
||||
row={r}
|
||||
onEntity={(e) => setRows((rs) => setRowEntity(rs, r.key, e))}
|
||||
onApplyGroup={() =>
|
||||
r.entity &&
|
||||
setRows((rs) =>
|
||||
setRowEntity(
|
||||
rs.map((x) => (x.group === r.group ? { ...x, touched: false } : x)),
|
||||
r.key,
|
||||
r.entity,
|
||||
),
|
||||
)
|
||||
}
|
||||
groupSize={rows.filter((x) => x.group === r.group).length}
|
||||
onChange={(p) => update(r.key, p)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{step === 1 ? (
|
||||
<div className="max-w-2xl space-y-5">
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">动作</h3>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{(["copy", "move"] as const).map((a) => {
|
||||
const disabled = a === "move" && anyHnr;
|
||||
return (
|
||||
<button
|
||||
key={a}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setAction(a)}
|
||||
aria-pressed={action === a}
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5 text-left transition-colors duration-micro disabled:cursor-not-allowed disabled:opacity-50",
|
||||
action === a ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<span className="block text-body font-medium">{a === "copy" ? "Copy" : "Move"}</span>
|
||||
<span className="block text-meta text-muted-foreground">
|
||||
{a === "copy"
|
||||
? "保留源文件,做种不受影响"
|
||||
: disabled
|
||||
? "含做种中且 H&R 未满足的文件,不能 Move"
|
||||
: "入库后删源;跨卷为 Copy → 核验 → 删源"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-2">
|
||||
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">目标库根</h3>
|
||||
<div className="space-y-1.5">
|
||||
<RootOption
|
||||
checked={!targetRoot}
|
||||
onSelect={() => setTargetRoot("")}
|
||||
title="自动"
|
||||
hint="按作品已有路径绑定或类型选择"
|
||||
/>
|
||||
{libRoots.map((r) => (
|
||||
<RootOption
|
||||
key={r.id}
|
||||
checked={targetRoot === r.id}
|
||||
onSelect={() => setTargetRoot(r.id)}
|
||||
title={r.name}
|
||||
hint={`${r.hostPath}${r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="space-y-1 rounded-xl border border-border px-3 py-2.5 text-caption text-muted-foreground">
|
||||
<p>命名:按系统设置里的命名模板重命名;已绑定的作品/季沿用原目录。</p>
|
||||
<p>目标已存在:不覆盖,列为冲突进待处理。</p>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{step === 2 ? (
|
||||
dry.isPending ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成整理计划…
|
||||
</p>
|
||||
) : preview ? (
|
||||
<div className="space-y-4">
|
||||
{preview.map((g) => (
|
||||
<GroupCard
|
||||
key={`${g.mediaEntityId}-${g.contentUnitId}`}
|
||||
g={g}
|
||||
title={entityTitle.get(g.mediaEntityId)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-caption text-destructive">{filesErrorText(dry.error, "预览失败")}</p>
|
||||
)
|
||||
) : null}
|
||||
|
||||
{step === 3 ? (
|
||||
exec.isPending || !result ? (
|
||||
<p className="flex items-center gap-2 text-caption text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> 提交整理任务…
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{result.groups.map((g) => {
|
||||
const t = entityTitle.get(g.mediaEntityId);
|
||||
return (
|
||||
<div
|
||||
key={`${g.mediaEntityId}-${g.contentUnitId}`}
|
||||
className="flex flex-wrap items-center gap-2 rounded-xl border border-border px-3 py-2.5"
|
||||
>
|
||||
{g.error ? (
|
||||
<StatusBadge tone="destructive">失败</StatusBadge>
|
||||
) : g.recordId ? (
|
||||
<StatusBadge tone="success">已提交</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge>未执行</StatusBadge>
|
||||
)}
|
||||
<span className="text-body">
|
||||
{t ? `${t.title}${t.year ? ` (${t.year})` : ""}` : g.mediaEntityId}
|
||||
</span>
|
||||
<span className="text-meta text-muted-foreground">{g.files.length} 个文件</span>
|
||||
{g.error ? <span className="w-full text-meta text-destructive">{g.error.message}</span> : null}
|
||||
{g.recordId ? (
|
||||
<Link className="ml-auto text-meta underline" to={`/console/organize/${g.recordId}`}>
|
||||
整理记录
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<p className="text-caption text-muted-foreground">
|
||||
整理在后台执行(逐文件可恢复),完成后列表会自动刷新状态。
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--shell-border)] px-5 py-3">
|
||||
<div>
|
||||
{step > 0 && step < 3 ? (
|
||||
<Button variant="ghost" onClick={() => setStep(step === 2 ? 0 : step - 1)}>
|
||||
<ArrowLeft />
|
||||
{step === 2 ? "返回修改" : "上一步"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{step === 0 ? (
|
||||
<Button onClick={() => setStep(1)} disabled={!rows.length || problems.length > 0 || active.length === 0}>
|
||||
下一步
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
) : null}
|
||||
{step === 1 ? (
|
||||
<Button onClick={runPreview}>
|
||||
生成预览
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
) : null}
|
||||
{step === 2 ? (
|
||||
<Button onClick={runExec} disabled={!okGroups.length || dry.isPending}>
|
||||
执行 {okGroups.length} 组
|
||||
<ArrowRight />
|
||||
</Button>
|
||||
) : null}
|
||||
{step === 3 && result ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
关闭
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!result.recordIds.length}
|
||||
onClick={() => {
|
||||
onClose();
|
||||
navigate(recordsHref);
|
||||
}}
|
||||
>
|
||||
<FileStack />
|
||||
查看整理记录
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function RootOption({
|
||||
checked,
|
||||
onSelect,
|
||||
title,
|
||||
hint,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onSelect: () => void;
|
||||
title: string;
|
||||
hint: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={checked}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition-colors duration-micro",
|
||||
checked ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"grid h-4 w-4 place-items-center rounded-full border",
|
||||
checked ? "border-foreground" : "border-border",
|
||||
)}
|
||||
>
|
||||
{checked ? <span className="h-2 w-2 rounded-full bg-foreground" /> : null}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-body">{title}</span>
|
||||
<span className="block truncate font-mono text-meta text-muted-foreground">{hint}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupCard({ g, title }: { g: FilesOrganizeGroup; title?: Entity }) {
|
||||
const dirs = [...new Set(g.files.map((f) => (f.target ? parentRel(f.target) : null)).filter(Boolean))];
|
||||
return (
|
||||
<section className="rounded-2xl border border-border">
|
||||
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<span className="text-body font-medium">
|
||||
{title ? `${title.title}${title.year ? ` (${title.year})` : ""}` : g.mediaEntityId}
|
||||
</span>
|
||||
{g.error ? (
|
||||
<StatusBadge tone="destructive">不能执行</StatusBadge>
|
||||
) : (
|
||||
<StatusBadge tone="success">计划就绪</StatusBadge>
|
||||
)}
|
||||
<span className="text-meta text-muted-foreground">
|
||||
{g.files.length} 个文件 ·{" "}
|
||||
{formatBytes(
|
||||
g.files.reduce((s, f) => s + f.size, 0),
|
||||
1,
|
||||
)}
|
||||
</span>
|
||||
</header>
|
||||
<div className="space-y-2 px-3 py-2.5">
|
||||
{g.error ? (
|
||||
<p className="flex items-start gap-1.5 text-caption text-destructive">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{g.error.message}(返回第 1 步修改后重新预览)
|
||||
</p>
|
||||
) : null}
|
||||
{g.warnings.map((w) => (
|
||||
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
|
||||
{dirs.length ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
目标目录:<span className="font-mono">{dirs.join("、")}</span>
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="space-y-1.5">
|
||||
{g.files.map((f) => (
|
||||
<li key={f.source} className="rounded-lg border border-border/60 px-2.5 py-1.5">
|
||||
<div className="mb-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
<span>{f.kind}</span>
|
||||
<span>{formatBytes(f.size, 1)}</span>
|
||||
{!f.target ? <StatusBadge tone="neutral">不入库</StatusBadge> : null}
|
||||
</div>
|
||||
<PathPair from={f.source} to={f.target} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function IdentifyRow({
|
||||
row,
|
||||
onEntity,
|
||||
onApplyGroup,
|
||||
groupSize,
|
||||
onChange,
|
||||
}: {
|
||||
row: WizardRow;
|
||||
onEntity: (e: Entity | null) => void;
|
||||
onApplyGroup: () => void;
|
||||
groupSize: number;
|
||||
onChange: (p: Partial<WizardRow>) => void;
|
||||
}) {
|
||||
const p = row.item.parsed;
|
||||
const problem = rowProblem(row);
|
||||
const isMovie = row.entity?.kind === "movie";
|
||||
return (
|
||||
<li
|
||||
className={cn(
|
||||
"rounded-xl border px-3 py-2.5",
|
||||
row.excluded ? "border-border opacity-60" : problem ? "border-warning/50 bg-warning/[0.06]" : "border-border",
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-mono text-caption text-foreground" title={row.path}>
|
||||
{baseName(row.path)}
|
||||
</p>
|
||||
<p className="text-meta text-muted-foreground">
|
||||
解析:{p.title ?? "?"}
|
||||
{p.year ? ` · ${p.year}` : ""}
|
||||
{p.season != null ? ` · ${contentUnitLabel(p.season, p.episodes)}` : ""}
|
||||
{p.resolution ? ` · ${p.resolution}` : ""}
|
||||
{p.source ? ` · ${p.source}` : ""}
|
||||
{p.releaseGroup ? ` · ${p.releaseGroup}` : ""}
|
||||
</p>
|
||||
{row.item.warnings.map((w) => (
|
||||
<p key={w} className="text-meta text-warning">
|
||||
{w}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onChange({ excluded: !row.excluded })}
|
||||
aria-label={row.excluded ? "恢复该项" : "排除该项"}
|
||||
>
|
||||
{row.excluded ? <Undo2 /> : <X />}
|
||||
{row.excluded ? "恢复" : "排除"}
|
||||
</Button>
|
||||
</div>
|
||||
{!row.excluded ? (
|
||||
<div className="mt-2 grid gap-2 sm:grid-cols-[minmax(0,1fr)_4.5rem_6.5rem_minmax(0,9rem)]">
|
||||
<div className="min-w-0">
|
||||
<EntityPicker candidates={row.item.candidates} value={row.entity} onChange={onEntity} />
|
||||
{groupSize > 1 && row.entity ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onApplyGroup}
|
||||
className="mt-1 text-meta text-muted-foreground underline hover:text-foreground"
|
||||
>
|
||||
应用到同目录 {groupSize} 项
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Input
|
||||
aria-label="季"
|
||||
placeholder="季"
|
||||
value={row.season}
|
||||
disabled={isMovie}
|
||||
onChange={(e) => onChange({ season: e.target.value })}
|
||||
className="h-9 text-center"
|
||||
/>
|
||||
<Input
|
||||
aria-label="集"
|
||||
placeholder="集 如 3-5"
|
||||
value={row.episodes}
|
||||
disabled={isMovie}
|
||||
onChange={(e) => onChange({ episodes: e.target.value })}
|
||||
className="h-9 text-center"
|
||||
/>
|
||||
<SlotSelect
|
||||
entityId={row.entity?.id ?? null}
|
||||
value={row.slotId}
|
||||
onChange={(slotId) => onChange({ slotId })}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
{problem ? <p className="mt-1.5 text-meta text-warning">{problem}</p> : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotSelect({
|
||||
entityId,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
entityId: string | null;
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
}) {
|
||||
const mock = filesMockEnabled();
|
||||
const slots = useSlots({ mediaEntityId: entityId ?? undefined }, !!entityId && !mock);
|
||||
const items = slots.data?.items ?? [];
|
||||
return (
|
||||
<select
|
||||
aria-label="Slot"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
disabled={!entityId}
|
||||
className="h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
|
||||
>
|
||||
<option value="">Slot:自动</option>
|
||||
{items.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name}({s.status})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function EntityPicker({
|
||||
candidates,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
candidates: FileIdentifyCandidate[];
|
||||
value: Entity | null;
|
||||
onChange: (e: Entity | null) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [q, setQ] = useState("");
|
||||
const mock = filesMockEnabled();
|
||||
const remote = useEntitySearch(mock ? "" : q);
|
||||
const mockHits = useQuery({
|
||||
queryKey: ["files", "mock-entity", q],
|
||||
enabled: mock && q.trim().length > 0,
|
||||
queryFn: async () => (await import("./mock.ts")).mockEntitySearch(q),
|
||||
});
|
||||
const results: Entity[] = mock
|
||||
? (mockHits.data ?? [])
|
||||
: (remote.data?.items ?? []).map((i) => ({
|
||||
id: i.entity.id,
|
||||
title: i.entity.title,
|
||||
year: i.entity.year,
|
||||
kind: i.entity.kind,
|
||||
}));
|
||||
const local = candidates.filter((c) => c.mediaEntityId);
|
||||
const remoteOnly = candidates.filter((c) => !c.mediaEntityId);
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex h-9 w-full min-w-0 items-center gap-2 rounded-lg border px-2.5 text-left text-caption",
|
||||
value ? "border-input" : "border-warning/60 text-warning",
|
||||
)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{value ? `${value.title}${value.year ? ` (${value.year})` : ""}` : "选择作品…"}
|
||||
</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-[min(26rem,calc(100vw-2rem))] p-2">
|
||||
{local.length ? (
|
||||
<p className="px-2 pt-1 pb-1.5 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
|
||||
候选
|
||||
</p>
|
||||
) : null}
|
||||
<ul className="space-y-0.5">
|
||||
{local.map((c) => (
|
||||
<li key={`${c.provider}-${c.mediaEntityId}`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ id: c.mediaEntityId as string, title: c.title, year: c.year, kind: c.kind });
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-foreground/[0.06]"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-caption">
|
||||
{value?.id === c.mediaEntityId ? <Check className="h-3.5 w-3.5" /> : <span className="w-3.5" />}
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{c.title}
|
||||
{c.year ? ` (${c.year})` : ""}
|
||||
</span>
|
||||
<StatusBadge tone={LEVEL[c.level]?.tone ?? "neutral"}>
|
||||
置信 {LEVEL[c.level]?.label ?? c.level}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
<span className="block pl-5.5 text-meta text-muted-foreground">{c.reasons.join(" · ")}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{remoteOnly.length ? (
|
||||
<p className="px-2 pt-2 text-meta text-muted-foreground">
|
||||
TMDB 另有 {remoteOnly.length} 个未入库候选(
|
||||
{remoteOnly.map((c) => `${c.title}${c.year ? ` ${c.year}` : ""}`).join("、")}),
|
||||
需先在“身份与元数据”建立作品。
|
||||
</p>
|
||||
) : null}
|
||||
<div className="relative mt-2">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
placeholder="搜索本地作品"
|
||||
className="h-8 pl-8 text-caption"
|
||||
/>
|
||||
</div>
|
||||
{results.length ? (
|
||||
<ul className="mt-1 max-h-48 space-y-0.5 overflow-auto">
|
||||
{results.map((e) => (
|
||||
<li key={e.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(e);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="w-full truncate rounded-lg px-2 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
|
||||
>
|
||||
{e.title}
|
||||
{e.year ? ` (${e.year})` : ""}{" "}
|
||||
<span className="text-muted-foreground">· {e.kind === "movie" ? "电影" : "剧集"}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : q.trim() ? (
|
||||
<p className="px-2 py-1.5 text-meta text-muted-foreground">没有匹配的本地作品</p>
|
||||
) : null}
|
||||
{value ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange(null);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="mt-1 w-full rounded-lg px-2 py-1.5 text-left text-meta text-muted-foreground hover:bg-foreground/[0.06]"
|
||||
>
|
||||
清除选择
|
||||
</button>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { ChevronRight, HardDrive } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { collapseCrumbs, segments } from "./model.ts";
|
||||
|
||||
/**
|
||||
* 路径面包屑:根名 + 各段,可点击跳转;每段也是拖放目标(拖入 = 移动到该目录)。
|
||||
* 窄屏折叠为"根 / … / 父 / 当前"。
|
||||
*/
|
||||
export function PathBreadcrumb({
|
||||
rootName,
|
||||
path,
|
||||
onNavigate,
|
||||
onDropTo,
|
||||
className,
|
||||
}: {
|
||||
rootName: string;
|
||||
path: string;
|
||||
onNavigate: (path: string) => void;
|
||||
/** 有值时每段可作为拖放目标 */
|
||||
onDropTo?: (path: string, e: React.DragEvent) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const segs = segments(path);
|
||||
const crumbs = [
|
||||
{ label: rootName, path: "" },
|
||||
...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })),
|
||||
];
|
||||
const [over, setOver] = useState<string | null>(null);
|
||||
|
||||
const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => (
|
||||
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
|
||||
{items.map((c, i) => {
|
||||
const last = i === items.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={c ? c.path || "__root" : `gap-${i}`}
|
||||
className={cn("flex min-w-0 items-center gap-0.5", last && "shrink")}
|
||||
>
|
||||
{i > 0 ? <ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
{c === null ? (
|
||||
<span className="px-1 text-muted-foreground">…</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(c.path)}
|
||||
aria-current={last ? "page" : undefined}
|
||||
onDragOver={
|
||||
onDropTo
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
|
||||
setOver(c.path);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={onDropTo ? () => setOver(null) : undefined}
|
||||
onDrop={
|
||||
onDropTo
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
setOver(null);
|
||||
onDropTo(c.path, e);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={c.label}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 truncate rounded-md px-1.5 py-1 text-body transition-colors duration-micro",
|
||||
last
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
over === c.path && "bg-foreground/[0.08] ring-1 ring-ring",
|
||||
)}
|
||||
>
|
||||
{i === 0 ? <HardDrive className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span className="truncate">{c.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
|
||||
{render(crumbs, "full", "hidden md:flex")}
|
||||
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export default [
|
||||
route("identity", "routes/console/identity.tsx"),
|
||||
route("organize", "routes/console/organize.tsx"),
|
||||
route("organize/:id", "routes/console/organize.$id.tsx"),
|
||||
route("files", "routes/console/files.tsx"),
|
||||
route("files/trash", "routes/console/files.trash.tsx"),
|
||||
route("subscriptions", "routes/console/subscriptions.tsx"),
|
||||
route("preferences", "routes/console/preferences.tsx"),
|
||||
route("sites", "routes/console/sites.tsx"),
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import { ArrowLeft, Clock, File as FileIcon, Folder, LoaderCircle, RotateCcw, Trash, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import {
|
||||
type FileRef,
|
||||
filesErrorText,
|
||||
filesMockEnabled,
|
||||
type TrashItem,
|
||||
useFileRoots,
|
||||
usePurge,
|
||||
useRestore,
|
||||
useTrash,
|
||||
} from "~/api/files.ts";
|
||||
import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
|
||||
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { DirTree } from "~/components/files/dir-tree.tsx";
|
||||
import { Checkbox } from "~/components/files/file-list.tsx";
|
||||
import { baseName, newIdempotencyKey } from "~/components/files/model.ts";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "~/components/ui/dialog.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { formatBytes, formatDateTime } from "~/lib/utils.ts";
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
function expiryTone(iso: string): "destructive" | "warning" | "neutral" {
|
||||
const left = Date.parse(iso) - Date.now();
|
||||
if (left <= 0) return "destructive";
|
||||
if (left < DAY) return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export default function FilesTrashPage() {
|
||||
const trash = useTrash();
|
||||
const roots = useFileRoots();
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.actor?.type === "admin";
|
||||
const restore = useRestore();
|
||||
const purge = usePurge();
|
||||
const [picked, setPicked] = useState<Set<string>>(new Set());
|
||||
const [restoreTo, setRestoreTo] = useState<string[] | null>(null);
|
||||
const [purgeAll, setPurgeAll] = useState(false);
|
||||
const [purgeIds, setPurgeIds] = useState<string[] | null>(null);
|
||||
const [confirmText, setConfirmText] = useState("");
|
||||
const rows = trash.data?.pages.flatMap((p) => p.items);
|
||||
const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]);
|
||||
const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? [];
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setPicked((p) => {
|
||||
const n = new Set(p);
|
||||
if (n.has(id)) n.delete(id);
|
||||
else n.add(id);
|
||||
return n;
|
||||
});
|
||||
|
||||
const doRestore = (ids: string[], target?: FileRef) =>
|
||||
restore.mutate(
|
||||
{ idempotencyKey: newIdempotencyKey(), ids, target },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
setPicked(new Set());
|
||||
setRestoreTo(null);
|
||||
if (r.restored.length) toast.success(`已还原 ${r.restored.length} 项`);
|
||||
for (const f of r.failed)
|
||||
toast.error(
|
||||
f.code === "exists" || f.code === "conflict" ? "原位置已被占用,可用“还原到…”换个目录" : f.message,
|
||||
{ description: rows?.find((x) => x.id === f.id)?.originalPath },
|
||||
);
|
||||
},
|
||||
onError: (e) => toast.error(filesErrorText(e, "还原失败")),
|
||||
},
|
||||
);
|
||||
|
||||
const doPurge = (ids?: string[]) =>
|
||||
purge
|
||||
.mutateAsync({ idempotencyKey: newIdempotencyKey(), ids })
|
||||
.then((r) => {
|
||||
setPicked(new Set());
|
||||
toast.success(`已清除 ${r.purged.length} 项${r.failed.length ? `,${r.failed.length} 项失败` : ""}`);
|
||||
})
|
||||
.catch((e) => {
|
||||
toast.error(filesErrorText(e, "清除失败"));
|
||||
throw e;
|
||||
});
|
||||
|
||||
const columns: DataColumn<TrashItem>[] = [
|
||||
{
|
||||
key: "pick",
|
||||
header: (
|
||||
<Checkbox
|
||||
checked={!!rows?.length && picked.size === rows.length}
|
||||
partial={picked.size > 0 && picked.size < (rows?.length ?? 0)}
|
||||
label="全选"
|
||||
onToggle={() => setPicked(picked.size === rows?.length ? new Set() : new Set(rows?.map((r) => r.id)))}
|
||||
/>
|
||||
),
|
||||
className: "w-10",
|
||||
cell: (r) => (
|
||||
<Checkbox checked={picked.has(r.id)} label={`选择 ${baseName(r.originalPath)}`} onToggle={() => toggle(r.id)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "原位置",
|
||||
cell: (r) => {
|
||||
const root = rootName.get(r.rootId);
|
||||
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null } | null;
|
||||
return (
|
||||
<div className="flex min-w-0 max-w-lg items-start gap-2">
|
||||
{r.isDir ? (
|
||||
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-foreground" title={r.originalPath}>
|
||||
{baseName(r.originalPath)}
|
||||
</p>
|
||||
<p className="truncate font-mono text-meta text-muted-foreground" title={r.trashPath}>
|
||||
{root?.name ?? r.rootId} / {r.originalPath}
|
||||
</p>
|
||||
{snap?.title ? (
|
||||
<p className="text-meta text-muted-foreground">
|
||||
删除时关联:{snap.title}
|
||||
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原不会自动恢复资产)
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "size",
|
||||
header: "大小",
|
||||
hideBelow: "sm",
|
||||
align: "right",
|
||||
cell: (r) => <span className="whitespace-nowrap">{formatBytes(r.size, 1)}</span>,
|
||||
},
|
||||
{
|
||||
key: "deleted",
|
||||
header: "删除",
|
||||
hideBelow: "md",
|
||||
cell: (r) => (
|
||||
<span
|
||||
className="block max-w-40 text-muted-foreground"
|
||||
title={`${formatDateTime(r.deletedAt)}${r.actor ? ` · ${r.actor}` : ""}`}
|
||||
>
|
||||
<span className="block whitespace-nowrap">{relTime(r.deletedAt)}</span>
|
||||
{r.actor ? (
|
||||
<span className="block truncate text-meta">{r.actor.replace(/^admin_session:/, "管理员 ")}</span>
|
||||
) : null}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "expires",
|
||||
header: "到期清除",
|
||||
cell: (r) => (
|
||||
<span title={formatDateTime(r.expiresAt)}>
|
||||
<StatusBadge tone={expiryTone(r.expiresAt)}>
|
||||
{Date.parse(r.expiresAt) <= Date.now() ? "已到期" : relTime(r.expiresAt)}
|
||||
</StatusBadge>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "act",
|
||||
header: <span className="sr-only">操作</span>,
|
||||
align: "right",
|
||||
cell: (r) => (
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
|
||||
<RotateCcw />
|
||||
还原
|
||||
</Button>
|
||||
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
|
||||
还原到…
|
||||
</Button>
|
||||
{isAdmin ? (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
aria-label="立即清除"
|
||||
title="立即清除"
|
||||
onClick={() => setPurgeIds([r.id])}
|
||||
>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="回收站"
|
||||
description="删除的文件先移到这里,保留 7 天后由后台任务清除;期间可还原到原位置或其他目录。"
|
||||
actions={
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
<Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<ArrowLeft />
|
||||
文件
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={!picked.size || restore.isPending}
|
||||
onClick={() => doRestore([...picked])}
|
||||
>
|
||||
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
|
||||
还原选中 {picked.size || ""}
|
||||
</Button>
|
||||
{isAdmin ? (
|
||||
<>
|
||||
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
|
||||
<Trash2 />
|
||||
清除选中
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
trigger={
|
||||
<Button size="sm" variant="outline" disabled={!expired.length}>
|
||||
<Clock />
|
||||
清除已到期 {expired.length || ""}
|
||||
</Button>
|
||||
}
|
||||
title="清除已到期的项?"
|
||||
description={`${expired.length} 项已过保留期,清除后不可恢复。`}
|
||||
confirmLabel="清除"
|
||||
destructive
|
||||
onConfirm={() => doPurge(undefined)}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="ml-auto"
|
||||
disabled={!rows?.length}
|
||||
onClick={() => setPurgeAll(true)}
|
||||
>
|
||||
<Trash />
|
||||
清空回收站
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<span className="ml-auto text-meta text-muted-foreground">立即清除仅管理员会话可用。</span>
|
||||
)}
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(r) => r.id}
|
||||
loading={trash.isPending}
|
||||
error={trash.error}
|
||||
onRetry={() => void trash.refetch()}
|
||||
hasMore={trash.hasNextPage}
|
||||
onLoadMore={() => void trash.fetchNextPage()}
|
||||
loadingMore={trash.isFetchingNextPage}
|
||||
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会先移到这里。" }}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!purgeIds}
|
||||
onOpenChange={(o) => !o && setPurgeIds(null)}
|
||||
title={`立即清除 ${purgeIds?.length ?? 0} 项?`}
|
||||
description="清除后不可恢复。"
|
||||
confirmLabel="清除"
|
||||
destructive
|
||||
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
|
||||
/>
|
||||
|
||||
{/* 清空全部:二次确认,需输入“清空回收站” */}
|
||||
<ConfirmDialog
|
||||
open={purgeAll}
|
||||
onOpenChange={(o) => {
|
||||
setPurgeAll(o);
|
||||
if (!o) setConfirmText("");
|
||||
}}
|
||||
title="清空回收站?"
|
||||
description={`将立即清除全部 ${rows?.length ?? 0} 项(含未到期的),不可恢复。`}
|
||||
confirmLabel="清空"
|
||||
destructive
|
||||
onConfirm={() => {
|
||||
if (confirmText.trim() !== "清空回收站") {
|
||||
toast.error("请输入“清空回收站”确认");
|
||||
return Promise.reject(new Error("unconfirmed"));
|
||||
}
|
||||
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
|
||||
}}
|
||||
>
|
||||
<Input
|
||||
value={confirmText}
|
||||
onChange={(e) => setConfirmText(e.target.value)}
|
||||
placeholder="输入 清空回收站"
|
||||
aria-label="输入 清空回收站 以确认"
|
||||
/>
|
||||
</ConfirmDialog>
|
||||
|
||||
{restoreTo ? (
|
||||
<RestoreToDialog
|
||||
ids={restoreTo}
|
||||
onClose={() => setRestoreTo(null)}
|
||||
onPick={(t) => doRestore(restoreTo, t)}
|
||||
pending={restore.isPending}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RestoreToDialog({
|
||||
ids,
|
||||
onClose,
|
||||
onPick,
|
||||
pending,
|
||||
}: {
|
||||
ids: string[];
|
||||
onClose: () => void;
|
||||
onPick: (t: FileRef) => void;
|
||||
pending: boolean;
|
||||
}) {
|
||||
const roots = useFileRoots();
|
||||
const [target, setTarget] = useState<FileRef | null>(null);
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>还原到…</DialogTitle>
|
||||
<DialogDescription>{ids.length} 项 · 选一个可写的目录;同名已存在的项会失败,不会覆盖。</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-80 overflow-y-auto rounded-xl border border-border p-1.5">
|
||||
<DirTree roots={roots.data ?? []} current={target} onSelect={setTarget} pickOnly />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button disabled={!target || pending} onClick={() => target && onPick(target)}>
|
||||
{pending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
|
||||
还原到这里
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { EventType } from "@umt/contracts";
|
||||
import {
|
||||
ArrowDownAZ,
|
||||
ArrowUpAZ,
|
||||
ClipboardCopy,
|
||||
Copy,
|
||||
Eye,
|
||||
EyeOff,
|
||||
FileStack,
|
||||
FolderInput,
|
||||
FolderOpen,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Library,
|
||||
PenLine,
|
||||
RotateCw,
|
||||
ScanSearch,
|
||||
Search,
|
||||
SearchX,
|
||||
ServerCrash,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wand2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { useSession } from "~/api/auth.ts";
|
||||
import {
|
||||
type FileEntry,
|
||||
type FileRef,
|
||||
filesErrorText,
|
||||
filesKeys,
|
||||
filesMockEnabled,
|
||||
isRootOffline,
|
||||
type ListQuery,
|
||||
refKey,
|
||||
useFileList,
|
||||
useFileRoots,
|
||||
useProbe,
|
||||
} from "~/api/files.ts";
|
||||
import { EmptyState, ErrorState, LoadingState } from "~/components/console/empty-state.tsx";
|
||||
import { PageHeader } from "~/components/console/page-header.tsx";
|
||||
import { StatusBadge } from "~/components/console/status-badge.tsx";
|
||||
import { ContextMenu, type MenuAnchor, type MenuItem } from "~/components/files/context-menu.tsx";
|
||||
import {
|
||||
CopyPathDialog,
|
||||
DeleteDialog,
|
||||
MkdirDialog,
|
||||
RenameDialog,
|
||||
ScrapeDialog,
|
||||
TransferDialog,
|
||||
} from "~/components/files/dialogs.tsx";
|
||||
import { DirTree } from "~/components/files/dir-tree.tsx";
|
||||
import { FileDetail } from "~/components/files/file-detail.tsx";
|
||||
import { DRAG_MIME, FileList } from "~/components/files/file-list.tsx";
|
||||
import {
|
||||
emptySelection,
|
||||
FILTER_CHIPS,
|
||||
opDisabledReason,
|
||||
parentRel,
|
||||
pruneSelection,
|
||||
type Selection,
|
||||
SORT_OPTIONS,
|
||||
totalSize,
|
||||
} from "~/components/files/model.ts";
|
||||
import { OrganizeWizard } from "~/components/files/organize-wizard.tsx";
|
||||
import { PathBreadcrumb } from "~/components/files/path-breadcrumb.tsx";
|
||||
import { Button, buttonVariants } from "~/components/ui/button.tsx";
|
||||
import { Input } from "~/components/ui/input.tsx";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
|
||||
import { relTime } from "~/components/workbench/release-fields.ts";
|
||||
import { useSse } from "~/lib/sse.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
|
||||
const HEALTH_REASON: Record<string, string> = {
|
||||
root_missing: "挂载点不存在",
|
||||
sentinel_missing: "根目录里找不到哨兵文件,可能挂载未生效",
|
||||
timeout: "探测超时",
|
||||
permission_denied: "没有读取权限",
|
||||
};
|
||||
const healthReasonText = (r: string | null) => (r ? (HEALTH_REASON[r] ?? r) : "挂载点没有响应");
|
||||
|
||||
type Dialog =
|
||||
| { kind: "rename"; entry: FileEntry }
|
||||
| { kind: "transfer"; items: FileRef[]; mode: "move" | "copy"; target?: FileRef | null }
|
||||
| { kind: "delete"; items: FileRef[] }
|
||||
| { kind: "mkdir" }
|
||||
| { kind: "copyPath"; entry: FileEntry }
|
||||
| { kind: "scrape"; entry: FileEntry }
|
||||
| { kind: "wizard"; entries: FileEntry[] };
|
||||
|
||||
const TREE_W_KEY = "umt-files-tree-w";
|
||||
const FILES_CHANGED = "files.changed";
|
||||
|
||||
function useIsMobile() {
|
||||
const [m, setM] = useState(false);
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia("(max-width: 767px)");
|
||||
const on = () => setM(mq.matches);
|
||||
on();
|
||||
mq.addEventListener("change", on);
|
||||
return () => mq.removeEventListener("change", on);
|
||||
}, []);
|
||||
return m;
|
||||
}
|
||||
|
||||
function useTreeWidth() {
|
||||
const [w, setW] = useState(260);
|
||||
useEffect(() => {
|
||||
try {
|
||||
const v = Number(localStorage.getItem(TREE_W_KEY));
|
||||
if (v >= 200 && v <= 440) setW(v);
|
||||
} catch {
|
||||
// 存储不可用时用默认宽度
|
||||
}
|
||||
}, []);
|
||||
const save = (v: number) => {
|
||||
const c = Math.max(200, Math.min(440, Math.round(v)));
|
||||
setW(c);
|
||||
try {
|
||||
localStorage.setItem(TREE_W_KEY, String(c));
|
||||
} catch {
|
||||
// 忽略
|
||||
}
|
||||
};
|
||||
return [w, save] as const;
|
||||
}
|
||||
|
||||
export default function FilesPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const session = useSession();
|
||||
const isAdmin = session.data?.actor?.type === "admin";
|
||||
const mobile = useIsMobile();
|
||||
const [treeW, setTreeW] = useTreeWidth();
|
||||
const roots = useFileRoots();
|
||||
const rootList = roots.data ?? [];
|
||||
|
||||
const rootId = params.get("root") ?? "";
|
||||
const path = params.get("path") ?? "";
|
||||
const sort = (params.get("sort") ?? "name") as ListQuery["sort"];
|
||||
const order = (params.get("order") ?? "asc") as "asc" | "desc";
|
||||
const filter = params.get("f") ?? "";
|
||||
const hidden = params.get("hidden") === "1";
|
||||
const selParam = params.get("sel");
|
||||
const [qInput, setQInput] = useState(params.get("q") ?? "");
|
||||
const q = params.get("q") ?? "";
|
||||
const root = rootList.find((r) => r.id === rootId);
|
||||
|
||||
const patch = useCallback(
|
||||
(p: Record<string, string | null>, replace = false) =>
|
||||
setParams(
|
||||
(prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
for (const [k, v] of Object.entries(p)) {
|
||||
if (v === null || v === "") next.delete(k);
|
||||
else next.set(k, v);
|
||||
}
|
||||
return next;
|
||||
},
|
||||
{ replace },
|
||||
),
|
||||
[setParams],
|
||||
);
|
||||
|
||||
// 默认进第一个在线的库根
|
||||
useEffect(() => {
|
||||
if (rootId || !rootList.length) return;
|
||||
const first = rootList.find((r) => r.health !== "offline") ?? rootList[0];
|
||||
if (first) patch({ root: first.id, path: null }, true);
|
||||
}, [rootId, rootList, patch]);
|
||||
|
||||
// 搜索 300 ms 防抖写回 URL
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (qInput.trim() !== q) patch({ q: qInput.trim() || null }, true);
|
||||
}, 300);
|
||||
return () => clearTimeout(t);
|
||||
}, [qInput, q, patch]);
|
||||
|
||||
const listQ: ListQuery | null =
|
||||
root && root.health !== "offline" ? { root: root.id, path, sort, order, q, filter, hidden } : null;
|
||||
const list = useFileList(listQ);
|
||||
const pages = useMemo(() => list.data?.pages ?? [], [list.data]);
|
||||
const entries = useMemo(() => pages.flatMap((p) => p.entries), [pages]);
|
||||
const first = pages[0];
|
||||
const keyOf = useCallback((e: FileEntry) => refKey({ root: rootId, path: e.path }), [rootId]);
|
||||
|
||||
// 目录快照变了(cursor stale)→ 从头重载
|
||||
const refetchList = list.refetch;
|
||||
useEffect(() => {
|
||||
if (pages.some((p) => p.stale)) void refetchList();
|
||||
}, [pages, refetchList]);
|
||||
|
||||
const [sel, setSel] = useState<Selection>(emptySelection);
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: 换目录清空选择
|
||||
useEffect(() => setSel(emptySelection()), [rootId, path]);
|
||||
useEffect(() => {
|
||||
setSel((s) => {
|
||||
const n = pruneSelection(s, entries.map(keyOf));
|
||||
return n.keys.size === s.keys.size && n.anchor === s.anchor && n.focus === s.focus ? s : n;
|
||||
});
|
||||
}, [entries, keyOf]);
|
||||
|
||||
const [dialog, setDialog] = useState<Dialog | null>(null);
|
||||
const [menu, setMenu] = useState<{ at: MenuAnchor; entry: FileEntry } | null>(null);
|
||||
const [treeOpen, setTreeOpen] = useState(false);
|
||||
const dragRefs = useRef<FileRef[]>([]);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
const searchRef = useRef<HTMLInputElement>(null);
|
||||
const probe = useProbe();
|
||||
|
||||
const selected = useMemo(() => entries.filter((e) => sel.keys.has(keyOf(e))), [entries, sel.keys, keyOf]);
|
||||
const detail = selParam != null ? (entries.find((e) => e.path === selParam) ?? null) : null;
|
||||
|
||||
// SSE:files.changed 与整理事件 → 刷新受影响目录
|
||||
useSse([FILES_CHANGED, EventType.organizationChanged], (ev) => {
|
||||
const p = ev.payload as { rootId?: string; dirs?: string[] } | undefined;
|
||||
if (ev.type === FILES_CHANGED && p?.rootId) {
|
||||
for (const d of p.dirs ?? [""]) {
|
||||
void qc.invalidateQueries({ queryKey: ["files", "list", p.rootId, d] });
|
||||
void qc.invalidateQueries({ queryKey: filesKeys.tree(p.rootId, d) });
|
||||
}
|
||||
} else void qc.invalidateQueries({ queryKey: ["files", "list"] });
|
||||
});
|
||||
|
||||
// "/" 聚焦搜索
|
||||
useEffect(() => {
|
||||
const on = (e: KeyboardEvent) => {
|
||||
if (e.key !== "/" || e.ctrlKey || e.metaKey) return;
|
||||
const t = e.target as HTMLElement;
|
||||
if (t.closest("input,textarea,select,[contenteditable=true],[role=dialog]")) return;
|
||||
e.preventDefault();
|
||||
searchRef.current?.focus();
|
||||
};
|
||||
window.addEventListener("keydown", on);
|
||||
return () => window.removeEventListener("keydown", on);
|
||||
}, []);
|
||||
|
||||
const go = (loc: { root: string; path: string }) => {
|
||||
setTreeOpen(false);
|
||||
patch({ root: loc.root, path: loc.path || null, sel: null, q: null });
|
||||
setQInput("");
|
||||
};
|
||||
const open = (e: FileEntry) => {
|
||||
if (e.escape) return toast.error("链接指向登记根之外,已禁用");
|
||||
if (e.type === "dir") go({ root: rootId, path: e.path });
|
||||
else patch({ sel: e.path });
|
||||
};
|
||||
const up = () => {
|
||||
if (path) go({ root: rootId, path: parentRel(path) });
|
||||
};
|
||||
|
||||
const refs = (list: FileEntry[]) => list.map((e) => ({ root: rootId, path: e.path }));
|
||||
const targets = (e?: FileEntry) => (e && !sel.keys.has(keyOf(e)) ? [e] : selected.length ? selected : e ? [e] : []);
|
||||
|
||||
const doProbe = (list: FileEntry[]) => {
|
||||
const vids = list.filter((e) => e.type === "file" && e.fileKind === "video").slice(0, 20);
|
||||
if (!vids.length) return toast.info("选中项里没有视频文件");
|
||||
probe.mutate(
|
||||
{ items: refs(vids) },
|
||||
{
|
||||
onSuccess: (r) => {
|
||||
const ok = r.items.filter((i) => i.status === "ok").length;
|
||||
toast.success(`探测完成:${ok}/${r.items.length} 个读到媒体信息`, {
|
||||
description: r.items.some((i) => i.savedToAsset)
|
||||
? "已登记资产已写回媒体信息"
|
||||
: "未登记文件的结果缓存 24 小时",
|
||||
});
|
||||
},
|
||||
onError: (err) => toast.error(filesErrorText(err, "探测失败")),
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const openDelete = (list: FileEntry[]) => {
|
||||
if (!root || !list.length) return;
|
||||
const blocked = list.map((e) => opDisabledReason("delete", e, root)).find(Boolean);
|
||||
if (blocked && list.length === 1) return toast.error(blocked);
|
||||
setDialog({ kind: "delete", items: refs(list) });
|
||||
};
|
||||
const openRename = (e: FileEntry) => {
|
||||
if (!root) return;
|
||||
const why = opDisabledReason("rename", e, root);
|
||||
if (why) return toast.error(why);
|
||||
setDialog({ kind: "rename", entry: e });
|
||||
};
|
||||
|
||||
const menuItems = (e: FileEntry): MenuItem[] => {
|
||||
if (!root) return [];
|
||||
const list = targets(e);
|
||||
const multi = list.length > 1;
|
||||
const why = (op: Parameters<typeof opDisabledReason>[0]) =>
|
||||
list.map((x) => opDisabledReason(op, x, root)).find(Boolean) ?? null;
|
||||
const items: MenuItem[] = [];
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "open",
|
||||
label: e.type === "dir" ? "打开" : "详情",
|
||||
icon: FolderOpen,
|
||||
hint: "Enter",
|
||||
onSelect: () => open(e),
|
||||
});
|
||||
if (multi)
|
||||
items.push({
|
||||
key: "wizard",
|
||||
label: `整理向导(${list.length} 项)`,
|
||||
icon: Wand2,
|
||||
disabled: why("organize"),
|
||||
onSelect: () => setDialog({ kind: "wizard", entries: list }),
|
||||
});
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "rename",
|
||||
label: "重命名",
|
||||
icon: PenLine,
|
||||
hint: "F2",
|
||||
disabled: why("rename"),
|
||||
onSelect: () => openRename(e),
|
||||
});
|
||||
items.push(
|
||||
{
|
||||
key: "move",
|
||||
label: "移动到…",
|
||||
icon: FolderInput,
|
||||
disabled: why("move"),
|
||||
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "move" }),
|
||||
},
|
||||
{
|
||||
key: "copy",
|
||||
label: "复制到…",
|
||||
icon: Copy,
|
||||
disabled: e.escape ? "链接指向登记根之外,已禁用" : null,
|
||||
onSelect: () => setDialog({ kind: "transfer", items: refs(list), mode: "copy" }),
|
||||
},
|
||||
);
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "organize",
|
||||
label: "整理到…",
|
||||
icon: Wand2,
|
||||
disabled: why("organize"),
|
||||
onSelect: () => setDialog({ kind: "wizard", entries: list }),
|
||||
});
|
||||
items.push({
|
||||
key: "identify",
|
||||
label: "识别",
|
||||
icon: Sparkles,
|
||||
disabled: why("identify"),
|
||||
onSelect: () => setDialog({ kind: "wizard", entries: list }),
|
||||
});
|
||||
if (!multi)
|
||||
items.push({
|
||||
key: "scrape",
|
||||
label: "刮削",
|
||||
icon: Library,
|
||||
disabled: why("scrape"),
|
||||
onSelect: () => setDialog({ kind: "scrape", entry: e }),
|
||||
});
|
||||
items.push({
|
||||
key: "probe",
|
||||
label: "探测",
|
||||
icon: ScanSearch,
|
||||
disabled: list.some((x) => x.type === "file" && x.fileKind === "video") ? null : "只能探测视频文件",
|
||||
onSelect: () => doProbe(list),
|
||||
});
|
||||
items.push({ key: "sep1", separator: true });
|
||||
if (!multi) {
|
||||
items.push({
|
||||
key: "copyPath",
|
||||
label: "复制路径…",
|
||||
icon: ClipboardCopy,
|
||||
onSelect: () => setDialog({ kind: "copyPath", entry: e }),
|
||||
});
|
||||
items.push({
|
||||
key: "openMedia",
|
||||
label: "打开作品",
|
||||
icon: Library,
|
||||
disabled: why("openMedia"),
|
||||
onSelect: () => e.asset && navigate(`/console/library/${e.asset.mediaEntityId}`),
|
||||
});
|
||||
items.push({
|
||||
key: "records",
|
||||
label: "在整理记录中查看",
|
||||
icon: FileStack,
|
||||
disabled: why("records"),
|
||||
onSelect: () => e.asset && navigate(`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`),
|
||||
});
|
||||
items.push({ key: "sep2", separator: true });
|
||||
}
|
||||
items.push({
|
||||
key: "delete",
|
||||
label: multi ? `删除 ${list.length} 项` : "删除",
|
||||
icon: Trash2,
|
||||
hint: "Del",
|
||||
destructive: true,
|
||||
disabled: why("delete"),
|
||||
onSelect: () => openDelete(list),
|
||||
});
|
||||
return items;
|
||||
};
|
||||
|
||||
// 拖拽:列表项 → 树节点 / 面包屑 / 列表里的目录 = 移动到(Alt 复制),一律先出 dryRun 预览
|
||||
const onDragRows = (e: FileEntry, ev: React.DragEvent) => {
|
||||
const k = keyOf(e);
|
||||
const list = sel.keys.has(k) ? selected : [e];
|
||||
if (!sel.keys.has(k)) setSel({ keys: new Set([k]), anchor: k, focus: k });
|
||||
dragRefs.current = refs(list);
|
||||
ev.dataTransfer.effectAllowed = "copyMove";
|
||||
ev.dataTransfer.setData(DRAG_MIME, JSON.stringify(dragRefs.current));
|
||||
ev.dataTransfer.setData("text/plain", list.map((x) => x.name).join("\n"));
|
||||
};
|
||||
const onDropTo = (target: FileRef, ev: React.DragEvent) => {
|
||||
const items = dragRefs.current;
|
||||
dragRefs.current = [];
|
||||
if (!items.length) return;
|
||||
if (items.some((i) => i.root === target.root && (i.path === target.path || parentRel(i.path) === target.path))) {
|
||||
if (items.every((i) => i.root === target.root && parentRel(i.path) === target.path)) return;
|
||||
}
|
||||
const tRoot = rootList.find((r) => r.id === target.root);
|
||||
if (!tRoot?.writable) return toast.error("只能拖到可写的媒体库根");
|
||||
setDialog({ kind: "transfer", items, mode: ev.altKey ? "copy" : "move", target });
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setQInput("");
|
||||
patch({ q: null, f: null });
|
||||
};
|
||||
|
||||
// ── 左栏 ──
|
||||
const tree = (
|
||||
<div className="space-y-3">
|
||||
{roots.isPending ? <LoadingState rows={4} /> : null}
|
||||
{roots.error ? <ErrorState plain error={roots.error} onRetry={() => void roots.refetch()} /> : null}
|
||||
{rootList.length ? (
|
||||
<DirTree roots={rootList} current={root ? { root: root.id, path } : null} onSelect={go} onDropTo={onDropTo} />
|
||||
) : null}
|
||||
{roots.data && !rootList.length ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={FolderTree}
|
||||
title="还没有可浏览的根"
|
||||
description="在系统设置里登记媒体库根或下载器路径映射后出现在这里。"
|
||||
className="px-2 py-8"
|
||||
/>
|
||||
) : null}
|
||||
<Link
|
||||
to="/console/files/trash"
|
||||
className="flex items-center gap-2 rounded-lg px-2 py-1.5 text-caption text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
回收站
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ── 列表区主体 ──
|
||||
const offline = list.error && isRootOffline(list.error);
|
||||
const filtered = !!(q || filter);
|
||||
let body: React.ReactNode;
|
||||
if (!root) {
|
||||
body = roots.isPending ? <LoadingState rows={8} className="p-4" /> : null;
|
||||
} else if (root.health === "offline" || offline) {
|
||||
body = (
|
||||
<div role="alert" className="flex flex-col items-center px-6 py-14 text-center">
|
||||
<span className="mb-4 grid h-11 w-11 place-items-center rounded-full bg-destructive/10 text-destructive">
|
||||
<ServerCrash className="h-5 w-5" />
|
||||
</span>
|
||||
<p className="text-lead font-medium text-foreground">{root.name} 离线</p>
|
||||
<p className="mt-1.5 max-w-md text-caption text-muted-foreground">
|
||||
{offline ? filesErrorText(list.error) : healthReasonText(root.healthReason)}
|
||||
。不会把离线当成空目录,也不会改变任何资产状态。
|
||||
</p>
|
||||
<p className="mt-1 text-meta text-muted-foreground">上次检查:{relTime(root.checkedAt)}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-5"
|
||||
onClick={() => {
|
||||
void roots.refetch();
|
||||
void list.refetch();
|
||||
}}
|
||||
>
|
||||
<RotateCw />
|
||||
重试
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
} else if (list.isPending) {
|
||||
body = <LoadingState rows={10} className="p-4" />;
|
||||
} else if (list.error && !entries.length) {
|
||||
body = (
|
||||
<ErrorState
|
||||
plain
|
||||
error={new Error(filesErrorText(list.error, "读取目录失败"))}
|
||||
onRetry={() => void list.refetch()}
|
||||
/>
|
||||
);
|
||||
} else if (!entries.length) {
|
||||
body = filtered ? (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={SearchX}
|
||||
title="没有符合筛选的项"
|
||||
description={q ? `搜索“${q}”没有结果;支持子串与 glob(如 *.mkv)。` : undefined}
|
||||
action={
|
||||
<Button variant="outline" size="sm" onClick={clearFilters}>
|
||||
<X />
|
||||
清除筛选
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<EmptyState
|
||||
plain
|
||||
icon={FolderOpen}
|
||||
title="空目录"
|
||||
description={hidden ? undefined : "隐藏文件未显示,可在工具栏打开。"}
|
||||
action={
|
||||
root.writable ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setDialog({ kind: "mkdir" })}>
|
||||
<FolderPlus />
|
||||
新建目录
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<FileList
|
||||
gridRef={gridRef}
|
||||
entries={entries}
|
||||
keyOf={keyOf}
|
||||
selection={sel}
|
||||
onSelection={setSel}
|
||||
onOpen={open}
|
||||
onUp={up}
|
||||
onMenu={(entry, at) => setMenu({ entry, at })}
|
||||
onRename={openRename}
|
||||
onDelete={openDelete}
|
||||
onDragRows={onDragRows}
|
||||
onDropToDir={(dir, ev) => onDropTo({ root: rootId, path: dir.path }, ev)}
|
||||
hasMore={!!list.hasNextPage}
|
||||
onLoadMore={() => void list.fetchNextPage()}
|
||||
loadingMore={list.isFetchingNextPage}
|
||||
mobile={mobile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const selCount = selected.length;
|
||||
const actionBar =
|
||||
selCount > 0 && root ? (
|
||||
<div
|
||||
className={cn(
|
||||
"pg-float z-30 flex items-center gap-1.5 overflow-x-auto rounded-full px-2 py-1.5 scroll-bleed",
|
||||
mobile
|
||||
? "fixed right-3 bottom-3 left-3"
|
||||
: "absolute bottom-12 left-1/2 max-w-[calc(100%-2rem)] -translate-x-1/2",
|
||||
)}
|
||||
role="toolbar"
|
||||
aria-label="批量操作"
|
||||
>
|
||||
<span className="shrink-0 px-2 text-caption whitespace-nowrap">
|
||||
已选 {selCount} 项{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setDialog({ kind: "wizard", entries: selected })}
|
||||
disabled={!!selected.map((e) => opDisabledReason("organize", e, root)).find(Boolean)}
|
||||
>
|
||||
<Wand2 />
|
||||
整理
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDialog({ kind: "transfer", items: refs(selected), mode: "move" })}
|
||||
disabled={!!selected.map((e) => opDisabledReason("move", e, root)).find(Boolean)}
|
||||
>
|
||||
<FolderInput />
|
||||
移动
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setDialog({ kind: "transfer", items: refs(selected), mode: "copy" })}
|
||||
>
|
||||
<Copy />
|
||||
复制
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => doProbe(selected)} disabled={probe.isPending}>
|
||||
<ScanSearch />
|
||||
探测
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
onClick={() => openDelete(selected)}
|
||||
disabled={!!selected.map((e) => opDisabledReason("delete", e, root)).find(Boolean)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
<Button size="icon-sm" variant="ghost" aria-label="清除选择" onClick={() => setSel(emptySelection())}>
|
||||
<X />
|
||||
</Button>
|
||||
</div>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="文件"
|
||||
description="只浏览已登记的库根与下载目录;每行带业务状态,删除进回收站,改名同步资产与路径绑定。"
|
||||
actions={
|
||||
<>
|
||||
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
|
||||
<Link to="/console/files/trash" className={buttonVariants({ variant: "outline", size: "sm" })}>
|
||||
<Trash2 />
|
||||
回收站
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 gap-4">
|
||||
{/* 桌面左栏:根 + 目录树(可拖宽) */}
|
||||
<aside
|
||||
className="pg-chrome relative hidden shrink-0 self-start overflow-hidden rounded-2xl md:block"
|
||||
style={{ width: treeW }}
|
||||
aria-label="根与目录树"
|
||||
>
|
||||
<div className="max-h-[calc(100dvh-11rem)] overflow-y-auto p-2.5">{tree}</div>
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: 可拖动、可键盘调整的分隔条,hr 不能获焦 */}
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label="调整目录树宽度"
|
||||
aria-valuenow={treeW}
|
||||
aria-valuemin={200}
|
||||
aria-valuemax={440}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowLeft") setTreeW(treeW - 16);
|
||||
if (e.key === "ArrowRight") setTreeW(treeW + 16);
|
||||
}}
|
||||
onPointerDown={(e) => {
|
||||
const startX = e.clientX;
|
||||
const startW = treeW;
|
||||
const move = (ev: PointerEvent) => setTreeW(startW + ev.clientX - startX);
|
||||
const upH = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", upH);
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", upH);
|
||||
}}
|
||||
className="absolute inset-y-0 right-0 w-1.5 cursor-col-resize hover:bg-foreground/[0.08] focus-visible:bg-foreground/[0.12] focus-visible:outline-none"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<section
|
||||
className="pg-slab relative flex h-[calc(100dvh-11rem)] min-h-[28rem] min-w-0 flex-1 flex-col overflow-hidden rounded-2xl"
|
||||
aria-label="目录内容"
|
||||
>
|
||||
{/* 工具栏 */}
|
||||
<div className="space-y-2 border-b border-border px-3 py-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
className="md:hidden"
|
||||
aria-label="根与目录"
|
||||
onClick={() => setTreeOpen(true)}
|
||||
>
|
||||
<FolderTree />
|
||||
</Button>
|
||||
{root ? (
|
||||
<PathBreadcrumb
|
||||
rootName={root.name}
|
||||
path={path}
|
||||
onNavigate={(p) => go({ root: root.id, path: p })}
|
||||
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
|
||||
className="min-w-0 flex-1"
|
||||
/>
|
||||
) : (
|
||||
<span className="flex-1" />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="刷新"
|
||||
title="刷新"
|
||||
onClick={() => {
|
||||
void roots.refetch();
|
||||
void list.refetch();
|
||||
}}
|
||||
>
|
||||
<RotateCw className={cn(list.isFetching && !list.isFetchingNextPage && "animate-spin")} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!root?.writable}
|
||||
title={root?.writable ? undefined : "只读根不能新建目录"}
|
||||
onClick={() => setDialog({ kind: "mkdir" })}
|
||||
>
|
||||
<FolderPlus />
|
||||
<span className="hidden sm:inline">新建目录</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="relative w-full sm:w-60">
|
||||
<Search className="pointer-events-none absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchRef}
|
||||
value={qInput}
|
||||
onChange={(e) => setQInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setQInput("");
|
||||
gridRef.current?.focus();
|
||||
}
|
||||
if (e.key === "ArrowDown") gridRef.current?.focus();
|
||||
}}
|
||||
placeholder="名称或 glob,如 *.mkv ( / )"
|
||||
aria-label="搜索当前目录"
|
||||
className="h-8 pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 overflow-x-auto scroll-bleed">
|
||||
{FILTER_CHIPS.map((c) => (
|
||||
<button
|
||||
key={c.value}
|
||||
type="button"
|
||||
aria-pressed={filter === c.value}
|
||||
onClick={() => patch({ f: c.value || null })}
|
||||
className={cn(
|
||||
"shrink-0 rounded-full border px-2.5 py-1 text-meta whitespace-nowrap transition-colors duration-micro",
|
||||
filter === c.value
|
||||
? "pill-selected text-foreground"
|
||||
: "border-border text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<select
|
||||
aria-label="排序"
|
||||
value={sort}
|
||||
onChange={(e) => patch({ sort: e.target.value === "name" ? null : e.target.value })}
|
||||
className="h-8 rounded-lg border border-input bg-transparent px-2 text-caption"
|
||||
>
|
||||
{SORT_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
按{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={order === "asc" ? "升序,点此改为降序" : "降序,点此改为升序"}
|
||||
title={order === "asc" ? "升序" : "降序"}
|
||||
onClick={() => patch({ order: order === "asc" ? "desc" : null })}
|
||||
>
|
||||
{order === "asc" ? <ArrowDownAZ /> : <ArrowUpAZ />}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-pressed={hidden}
|
||||
aria-label={hidden ? "隐藏点文件" : "显示隐藏文件"}
|
||||
title={hidden ? "正在显示隐藏文件" : "显示隐藏文件"}
|
||||
onClick={() => patch({ hidden: hidden ? null : "1" })}
|
||||
className={cn(hidden && "bg-foreground/[0.08] text-foreground")}
|
||||
>
|
||||
{hidden ? <Eye /> : <EyeOff />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{first?.truncated ? (
|
||||
<div role="status" className="border-b border-warning/30 bg-warning/10 px-3 py-1.5 text-meta text-warning">
|
||||
目录过大,只读取了前 {first.total.toLocaleString("zh-CN")} 项。请用搜索或进入子目录缩小范围。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">{body}</div>
|
||||
|
||||
{/* 状态栏 */}
|
||||
{root && !offline && root.health !== "offline" ? (
|
||||
<div className="flex items-center gap-3 border-t border-border px-3 py-1.5 text-meta text-muted-foreground">
|
||||
<span>
|
||||
{first ? `${entries.length.toLocaleString("zh-CN")} / ${first.total.toLocaleString("zh-CN")} 项` : "—"}
|
||||
</span>
|
||||
{selCount ? (
|
||||
<span>
|
||||
已选 {selCount}
|
||||
{totalSize(selected) ? ` · ${formatBytes(totalSize(selected), 1)}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
{root.usage ? <span className="ml-auto">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
|
||||
{!root.writable ? <StatusBadge>只读</StatusBadge> : null}
|
||||
</div>
|
||||
) : null}
|
||||
{!mobile ? actionBar : null}
|
||||
</section>
|
||||
</div>
|
||||
{mobile ? actionBar : null}
|
||||
|
||||
{/* 手机:根与目录树收进抽屉 */}
|
||||
<Sheet open={treeOpen} onOpenChange={setTreeOpen}>
|
||||
<SheetContent side="left" className="w-[85vw] gap-0 p-0 sm:max-w-sm">
|
||||
<SheetHeader className="border-b border-[var(--shell-border)] px-4 py-3">
|
||||
<SheetTitle>根与目录</SheetTitle>
|
||||
</SheetHeader>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">{tree}</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
|
||||
<ContextMenu anchor={menu?.at ?? null} items={menu ? menuItems(menu.entry) : []} onClose={() => setMenu(null)} />
|
||||
|
||||
{detail && root ? (
|
||||
<FileDetail
|
||||
root={root}
|
||||
entry={detail}
|
||||
onClose={() => patch({ sel: null })}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openRename(detail)}
|
||||
disabled={!!opDisabledReason("rename", detail, root)}
|
||||
>
|
||||
<PenLine />
|
||||
重命名
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDialog({ kind: "transfer", items: refs([detail]), mode: "move" })}
|
||||
disabled={!!opDisabledReason("move", detail, root)}
|
||||
>
|
||||
<FolderInput />
|
||||
移动
|
||||
</Button>
|
||||
{detail.asset ? null : (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setDialog({ kind: "wizard", entries: [detail] })}
|
||||
disabled={!!opDisabledReason("organize", detail, root)}
|
||||
>
|
||||
<Wand2 />
|
||||
整理
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => openDelete([detail])}
|
||||
disabled={!!opDisabledReason("delete", detail, root)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{dialog?.kind === "rename" && root ? (
|
||||
<RenameDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
{dialog?.kind === "transfer" ? (
|
||||
<TransferDialog
|
||||
roots={rootList}
|
||||
items={dialog.items}
|
||||
initialMode={dialog.mode}
|
||||
initialTarget={dialog.target}
|
||||
onClose={() => setDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
{dialog?.kind === "delete" ? (
|
||||
<DeleteDialog roots={rootList} items={dialog.items} isAdmin={isAdmin} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
{root ? (
|
||||
<MkdirDialog
|
||||
root={root}
|
||||
path={path}
|
||||
open={dialog?.kind === "mkdir"}
|
||||
onOpenChange={(o) => !o && setDialog(null)}
|
||||
/>
|
||||
) : null}
|
||||
{dialog?.kind === "copyPath" && root ? (
|
||||
<CopyPathDialog root={root} entry={dialog.entry} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
{dialog?.kind === "scrape" ? <ScrapeDialog entry={dialog.entry} onClose={() => setDialog(null)} /> : null}
|
||||
{dialog?.kind === "wizard" && root ? (
|
||||
<OrganizeWizard roots={rootList} entries={dialog.entries} rootId={root.id} onClose={() => setDialog(null)} />
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ const STATIC_ROUTES = [
|
||||
"/console/library",
|
||||
"/console/identity",
|
||||
"/console/organize",
|
||||
"/console/files",
|
||||
"/console/files/trash",
|
||||
"/console/subscriptions",
|
||||
"/console/preferences",
|
||||
"/console/sites",
|
||||
|
||||
Reference in new issue
Block a user