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 });
|
||||
Reference in new issue
Block a user