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]>
275 lines
10 KiB
TypeScript
275 lines
10 KiB
TypeScript
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 });
|