Files
UHD_Media_Tool/apps/web/app/api/files.ts
T
rootandClaude Opus 5.5 c31df8b69a feat(web): file manager v2 and shared organize wizard (FE-4)
- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px
- 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多"
- 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB
- 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行),
  TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、
  plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录
- 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…"
- 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-09-28 16:26:41 +08:00

428 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
cursorPage,
type FileEntry,
type FileRef,
FileRootList,
FileStat,
type FilesDeleteRequest,
FilesDeleteResult,
type FilesEntityFromCandidateRequest,
FilesEntityFromCandidateResult,
type FilesIdentifyRequest,
FilesIdentifyResult,
FilesListResponse,
type FilesMkdirRequest,
FilesMkdirResult,
type FilesMoveRequest,
type FilesOrganizeRequest,
FilesOrganizeResult,
type FilesProbeRequest,
FilesProbeResult,
FilesRecordSourcesResult,
FilesRecordsResult,
type FilesRenameRequest,
FilesRenameResult,
type FilesScrapeBatchRequest,
FilesScrapeBatchResult,
type FilesScrapeRequest,
FilesScrapeResult,
type FilesSuggestNameRequest,
FilesSuggestNameResult,
FilesTransferResult,
FilesTrashList,
type FilesTrashPurgeRequest,
FilesTrashPurgeResult,
type FilesTrashRestoreRequest,
FilesTrashRestoreResult,
FilesTreeResult,
LibraryItem,
SearchResponse,
type TmdbCandidate,
} 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,
FileParsed,
FileRef,
FileRoot,
FileStatus,
FilesOnExisting,
FilesOrganizeGroup,
FilesOrganizeItem,
FilesOrganizeRequest,
FilesScrapeBatchResult,
FilesSuggestNameResult,
FilesTransferResult,
FilesTreeResult,
NamingTemplateSource,
TmdbCandidate,
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,
records: (ids: string[], planIds: string[]) => ["files", "records", ids.join(","), planIds.join(",")] 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,
});
}
/** 目录树懒加载:GET /files/tree(子目录 + 是否还有下级 + 本目录视频/已登记计数 + 上一级) */
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(FilesTreeResult, "/files/tree", { params: { root, path }, signal }),
});
}
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, inline = false): string {
const qs = new URLSearchParams({ root, path });
if (inline) qs.set("inline", "true");
return `/api/v1/files/download?${qs}`;
}
/** 文本预览最多读前 256 KB(规格 §9) */
export const TEXT_PREVIEW_BYTES = 256 * 1024;
export type TextPreview = { text: string; truncated: boolean };
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 }): Promise<TextPreview> => {
if (filesMockEnabled())
return { text: await (await loadMock()).mockPreview(root ?? "", path ?? ""), truncated: false };
const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal });
if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`);
return readTextHead(res, TEXT_PREVIEW_BYTES);
},
});
}
/** 只读响应体前 max 字节(流式,读够就取消),按 UTF-8 解码 */
async function readTextHead(res: Response, max: number): Promise<TextPreview> {
const reader = res.body?.getReader();
if (!reader) {
const t = await res.text();
return { text: t.slice(0, max), truncated: t.length > max };
}
const chunks: Uint8Array[] = [];
let n = 0;
let truncated = false;
while (n < max) {
const { done, value } = await reader.read();
if (done || !value) break;
chunks.push(value);
n += value.length;
}
if (n >= max) {
truncated = n > max || !(await reader.read()).done;
await reader.cancel().catch(() => {});
}
const buf = new Uint8Array(Math.min(n, max));
let off = 0;
for (const c of chunks) {
const part = c.subarray(0, Math.min(c.length, buf.length - off));
buf.set(part, off);
off += part.length;
if (off >= buf.length) break;
}
return { text: new TextDecoder("utf-8", { fatal: false }).decode(buf), truncated };
}
/** 整理记录回查:ids 或 planIds(执行后映射到记录);执行中每 3 秒刷新 */
export function useFileRecords(q: { ids?: string[]; planIds?: string[] }, enabled = true) {
const ids = q.ids ?? [];
const planIds = q.planIds ?? [];
return useQuery({
queryKey: filesKeys.records(ids, planIds),
enabled: enabled && (ids.length > 0 || planIds.length > 0),
retry: false,
queryFn: ({ signal }) =>
call(FilesRecordsResult, "/files/records", {
params: { ids: ids.join(",") || undefined, planIds: planIds.join(",") || undefined },
signal,
}),
refetchInterval: (query) =>
query.state.data?.items.some((r) => r.status === "queued" || r.status === "running") ? 3_000 : false,
});
}
/** 整理记录 → 源 FileRef(整理记录页"重新整理") */
export function useRecordSources(recordId: string | null) {
return useQuery({
queryKey: ["files", "record-sources", recordId ?? ""],
enabled: !!recordId,
retry: false,
queryFn: ({ signal }) => call(FilesRecordSourcesResult, "/files/record-sources", { params: { recordId }, signal }),
});
}
/** 作品选择器里的本地作品 */
export type LocalEntityHit = { id: string; title: string; year: number | null; kind: string; alias: string | null };
export type MediaSearchResult = {
local: LocalEntityHit[];
tmdb: TmdbCandidate[];
tmdbConfigured: boolean;
};
/**
* 作品选择器:本地作品(/library 子串匹配 ∪ /search 别名匹配)+ TMDB 候选(/search?scope=media)。
* 两路并行,任一失败只少一组;mock 时只查内存数据。
*/
export function useMediaSearch(q: string) {
const t = q.trim();
return useQuery({
queryKey: ["files", "media-search", t],
enabled: t.length > 0,
staleTime: 60_000,
placeholderData: keepPreviousData,
retry: false,
queryFn: async ({ signal }): Promise<MediaSearchResult> => {
if (filesMockEnabled()) {
const m = await loadMock();
return m.mockMediaSearch(t);
}
const [lib, search] = await Promise.allSettled([
apiParsed(cursorPage(LibraryItem), "/api/v1/library", { params: { q: t, limit: 10 }, signal }),
apiParsed(SearchResponse, "/api/v1/search", { params: { q: t, scope: "media", limit: 8 }, signal }),
]);
if (lib.status === "rejected" && search.status === "rejected") throw lib.reason;
const local = new Map<string, LocalEntityHit>();
if (lib.status === "fulfilled")
for (const i of lib.value.items)
local.set(i.entity.id, {
id: i.entity.id,
title: i.entity.title,
year: i.entity.year,
kind: i.entity.kind,
alias: null,
});
const media = search.status === "fulfilled" ? search.value.media : null;
for (const h of media?.local.items ?? [])
if (!local.has(h.entity.id))
local.set(h.entity.id, {
id: h.entity.id,
title: h.entity.title,
year: h.entity.year,
kind: h.entity.kind,
alias: h.matchedAlias && h.matchedAlias !== h.entity.title ? h.matchedAlias : null,
});
return {
local: [...local.values()],
tmdb: media?.tmdb.items ?? [],
tmdbConfigured: media?.tmdb.configured ?? false,
};
},
});
}
// ─── 变更 ────────────────────────────────────────────────────────
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 useScrapeBatch = () =>
useMutation({ mutationFn: (b: FilesScrapeBatchRequest) => post(FilesScrapeBatchResult, "/files/scrape-batch", b) });
export const useSuggestName = () =>
useMutation({ mutationFn: (b: FilesSuggestNameRequest) => post(FilesSuggestNameResult, "/files/suggest-name", b) });
export const useEntityFromCandidate = () =>
useMutation({
mutationFn: (b: FilesEntityFromCandidateRequest) =>
post(FilesEntityFromCandidateResult, "/files/entity-from-candidate", 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 });