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]>
This commit is contained in:
1 parent
09a6b0b5d3
commit
c31df8b69a
24 files changed
+4820
-1607
No files matched your search
+164
-11
@@ -1,11 +1,14 @@
|
||||
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,
|
||||
@@ -16,16 +19,26 @@ import {
|
||||
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";
|
||||
@@ -39,11 +52,20 @@ export type {
|
||||
FileEntry,
|
||||
FileIdentifyCandidate,
|
||||
FileIdentifyItem,
|
||||
FileParsed,
|
||||
FileRef,
|
||||
FileRoot,
|
||||
FileStatus,
|
||||
FilesOnExisting,
|
||||
FilesOrganizeGroup,
|
||||
FilesOrganizeItem,
|
||||
FilesOrganizeRequest,
|
||||
FilesScrapeBatchResult,
|
||||
FilesSuggestNameResult,
|
||||
FilesTransferResult,
|
||||
FilesTreeResult,
|
||||
NamingTemplateSource,
|
||||
TmdbCandidate,
|
||||
TrashItem,
|
||||
} from "@umt/contracts";
|
||||
|
||||
@@ -126,6 +148,7 @@ export const filesKeys = {
|
||||
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() {
|
||||
@@ -178,18 +201,14 @@ export function useFileList(q: ListQuery | null) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 目录树懒加载:只取子目录(按名称),取前 1000 项 */
|
||||
/** 目录树懒加载: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(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 })),
|
||||
queryFn: ({ signal }) => call(FilesTreeResult, "/files/tree", { params: { root, path }, signal }),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -214,22 +233,147 @@ export function useTrash(enabled = true) {
|
||||
}
|
||||
|
||||
/** 非媒体小文件(NFO/字幕/图片 ≤ 20 MB)下载与预览地址 */
|
||||
export function fileDownloadUrl(root: string, path: string): string {
|
||||
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 }) => {
|
||||
if (filesMockEnabled()) return (await loadMock()).mockPreview(root ?? "", path ?? "");
|
||||
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})`);
|
||||
const text = await res.text();
|
||||
return text.length > 64_000 ? `${text.slice(0, 64_000)}\n…(已截断)` : text;
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -268,6 +412,15 @@ export const useOrganizeFiles = () =>
|
||||
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}`;
|
||||
|
||||
Reference in new issue
Block a user