From c31df8b69a07debc20718465bad27481990b09c5 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 28 Sep 2026 16:26:41 +0800 Subject: [PATCH] feat(web): file manager v2 and shared organize wizard (FE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 左树可折叠(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 --- apps/web/app/api/files.ts | 175 ++- apps/web/app/components/files/dialogs.tsx | 330 +++-- apps/web/app/components/files/dir-picker.tsx | 36 + apps/web/app/components/files/dir-tree.tsx | 23 +- apps/web/app/components/files/file-detail.tsx | 98 +- apps/web/app/components/files/file-list.tsx | 177 ++- .../app/components/files/identify-panel.tsx | 152 +++ apps/web/app/components/files/mock.ts | 318 ++++- apps/web/app/components/files/model.test.ts | 154 +-- apps/web/app/components/files/model.ts | 246 +++- .../app/components/files/organize-wizard.tsx | 852 ------------- .../app/components/files/shortcuts-dialog.tsx | 44 + apps/web/app/components/files/use-mobile.ts | 14 + .../organize-wizard/entity-picker.tsx | 284 +++++ .../app/components/organize-wizard/index.ts | 3 + .../components/organize-wizard/model.test.ts | 284 +++++ .../app/components/organize-wizard/model.ts | 422 +++++++ .../organize-wizard/organize-wizard.tsx | 520 ++++++++ .../organize-wizard/path-segments.tsx | 51 + .../components/organize-wizard/step-files.tsx | 519 ++++++++ .../organize-wizard/step-options.tsx | 222 ++++ .../organize-wizard/step-preview.tsx | 288 +++++ apps/web/app/routes/console/files.trash.tsx | 144 ++- apps/web/app/routes/console/files.tsx | 1071 +++++++++++------ 24 files changed, 4820 insertions(+), 1607 deletions(-) create mode 100644 apps/web/app/components/files/dir-picker.tsx create mode 100644 apps/web/app/components/files/identify-panel.tsx delete mode 100644 apps/web/app/components/files/organize-wizard.tsx create mode 100644 apps/web/app/components/files/shortcuts-dialog.tsx create mode 100644 apps/web/app/components/files/use-mobile.ts create mode 100644 apps/web/app/components/organize-wizard/entity-picker.tsx create mode 100644 apps/web/app/components/organize-wizard/index.ts create mode 100644 apps/web/app/components/organize-wizard/model.test.ts create mode 100644 apps/web/app/components/organize-wizard/model.ts create mode 100644 apps/web/app/components/organize-wizard/organize-wizard.tsx create mode 100644 apps/web/app/components/organize-wizard/path-segments.tsx create mode 100644 apps/web/app/components/organize-wizard/step-files.tsx create mode 100644 apps/web/app/components/organize-wizard/step-options.tsx create mode 100644 apps/web/app/components/organize-wizard/step-preview.tsx diff --git a/apps/web/app/api/files.ts b/apps/web/app/api/files.ts index 4ea8ebdd..cd744895 100644 --- a/apps/web/app/api/files.ts +++ b/apps/web/app/api/files.ts @@ -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 => { + 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 { + 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 => { + 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(); + 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}`; diff --git a/apps/web/app/components/files/dialogs.tsx b/apps/web/app/components/files/dialogs.tsx index 66520dd4..a6ed7e8f 100644 --- a/apps/web/app/components/files/dialogs.tsx +++ b/apps/web/app/components/files/dialogs.tsx @@ -5,9 +5,11 @@ import { FolderPlus, LoaderCircle, PenLine, - Sparkles, + RefreshCw, Trash2, TriangleAlert, + Undo2, + Wand2, } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router"; @@ -16,19 +18,22 @@ import { type FileEntry, type FileRef, type FileRoot, + type FilesScrapeBatchResult, + type FilesSuggestNameResult, type FilesTransferResult, filesErrorReason, filesErrorText, useDelete, useFileStat, - useIdentify, useMkdir, useMove, useRename, - useScrape, + useScrapeBatch, + useSuggestName, } from "~/api/files.ts"; import { FormField } from "~/components/console/form-field.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx"; +import { PathSegments } from "~/components/organize-wizard/path-segments.tsx"; import { Button } from "~/components/ui/button.tsx"; import { Dialog, @@ -41,15 +46,15 @@ import { 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 { DirPicker } from "./dir-picker.tsx"; import { baseName, deleteBlockText, nameProblem, + nameTaken, newIdempotencyKey, splitCommon, stemRange, - suggestName, } from "./model.ts"; // ─── 共用 ─────────────────────────────────────────────────────── @@ -177,29 +182,97 @@ export function MkdirDialog({ // ─── 重命名 ───────────────────────────────────────────────────── -export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: FileEntry; onClose: () => void }) { +const SUGGEST_REASON: Record = { + identity_uncertain: "识别不可信,没有推荐名", +}; +const TEMPLATE_SOURCE: Record = { entity: "作品覆盖", root: "库根", global: "全局", override: "覆写" }; + +function suggestReasonText(r: string | null): string { + if (!r) return "没有推荐名"; + if (SUGGEST_REASON[r]) return SUGGEST_REASON[r] as string; + if (r.startsWith("naming_failed")) return `命名模板渲染失败:${r.replace(/^naming_failed:\s*/, "")}`; + return r; +} + +/** + * 重命名(§8):默认值取命名模板推荐名(POST /files/suggest-name),用户没改过时自动填入; + * `按模板生成` 重新取;同名即时提示;输入停顿后 dryRun 列出一起改名的附属文件与资产/绑定同步。 + */ +export function RenameDialog({ + root, + entry, + siblings = [], + onClose, +}: { + root: FileRoot; + entry: FileEntry; + /** 当前目录其他项的名称(同名即时提示) */ + siblings?: string[]; + onClose: () => void; +}) { const isDir = entry.type === "dir"; const [name, setName] = useState(entry.name); + const edited = useRef(false); const [withSidecars, setWithSidecars] = useState(true); const [err, setErr] = useState(null); + const [suggested, setSuggested] = useState(null); const inputRef = useRef(null); const key = useMemo(() => newIdempotencyKey(), []); const preview = useRename(); const exec = useRename(); - const identify = useIdentify(); + const suggest = useSuggestName(); const debounced = useDebounced(name.trim(), 350); const problem = nameProblem(name); const changed = name.trim() !== entry.name; + const taken = nameTaken(name, entry.name, siblings); + const canSuggest = isDir || entry.fileKind === "video"; - useEffect(() => { + const selectStem = (v: string) => { const el = inputRef.current; if (!el) return; el.focus(); - const [a, b] = stemRange(entry.name, isDir); - el.setSelectionRange(a, b); - }, [entry.name, isDir]); + const [a, b] = stemRange(v, isDir); + requestAnimationFrame(() => el.setSelectionRange(a, b)); + }; - // 输入停顿后 dryRun:列出将一起改名的 sidecars 与资产/绑定同步 + // biome-ignore lint/correctness/useExhaustiveDependencies: 打开时选中主名一次 + useEffect(() => selectStem(entry.name), [entry.name, isDir]); + + const runSuggest = (apply: boolean) => + suggest.mutate( + { root: root.id, path: entry.path }, + { + onSuccess: (r) => { + setSuggested(r); + if (r.name && apply) { + setName(r.name); + setErr(null); + selectStem(r.name); + } + }, + }, + ); + + // 打开时取推荐名;用户还没动过输入框就直接填入 + const suggestMutate = suggest.mutate; + // biome-ignore lint/correctness/useExhaustiveDependencies: 只在打开时取一次 + useEffect(() => { + if (!canSuggest) return; + suggestMutate( + { root: root.id, path: entry.path }, + { + onSuccess: (r) => { + setSuggested(r); + if (r.name && r.name !== entry.name && !edited.current) { + setName(r.name); + selectStem(r.name); + } + }, + }, + ); + }, [suggestMutate]); + + // 输入停顿后 dryRun:列出将一起改名的附属文件与资产/绑定同步 const previewMutate = preview.mutate; useEffect(() => { if (!debounced || debounced === entry.name || nameProblem(debounced)) return; @@ -213,31 +286,17 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: }); }, [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(); + if (taken) return setErr("同名已存在,不会覆盖"); 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.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`, r.recordId ? { action: { @@ -253,9 +312,9 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: const reason = filesErrorReason(e); setErr( reason === "exists" - ? "目标名已存在,不会覆盖" + ? "同名已存在,不会覆盖" : reason === "download_active" - ? "正在做种,改名会让下载器找不到文件" + ? "做种中,改名会让下载器找不到文件" : filesErrorText(e), ); }, @@ -265,6 +324,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: const pv = preview.data && preview.variables?.newName === debounced ? preview.data : null; const pvErr = preview.error && preview.variables?.newName === debounced ? preview.error : null; + const usingSuggestion = !!suggested?.name && name.trim() === suggested.name; return ( !o && onClose()}> @@ -274,37 +334,85 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: 重命名 {entry.name} - +
{ setName(e.target.value); + edited.current = true; setErr(null); }} className="font-mono" /> - {entry.fileKind === "video" || isDir ? ( - ) : null}
+ {canSuggest ? ( +
+ {suggest.isPending && !suggested ? ( +

+ 取推荐名… +

+ ) : suggest.error ? ( +

{filesErrorText(suggest.error, "取推荐名失败")}

+ ) : suggested && !suggested.name ? ( +

{suggestReasonText(suggested.reason)}

+ ) : suggested?.name ? ( + <> +

+ {suggested.name === entry.name + ? "当前名称与命名模板一致" + : usingSuggestion + ? "已填入推荐名" + : `推荐名:${suggested.name}`} + {suggested.templateSource + ? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}` + : ""} + {usingSuggestion && name.trim() !== entry.name ? ( + + ) : null} +

+ {suggested.segments.length > 1 ? ( + + ) : null} + + ) : null} +
+ ) : null} {!isDir ? (
同名附属文件一起改 - 同 stem 的字幕 / NFO / 图片 + 主干相同的字幕、NFO、图片
) : null} - {changed && !problem ? ( + {changed && !problem && !taken ? (
{preview.isPending ? (

@@ -325,10 +433,10 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: ))} {pv.assetLocations > 0 ? ( - 将同步 {pv.assetLocations} 条资产位置,并写一条整理记录。 + 同步 {pv.assetLocations} 条资产位置,写一条整理记录。 ) : null} {pv.bindings.length > 0 ? ( - 这是作品/季的绑定目录。改名后旧绑定标为已替代,新集入库到新目录。 + 这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。 ) : null} ) : null} @@ -338,7 +446,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry: - @@ -418,7 +526,7 @@ export function TransferDialog({ onError: (e) => { const reason = filesErrorReason(e); if (reason === "plan_stale") { - setErr("源文件在预览后有变化,已重新预览。确认后再执行。"); + setErr("源文件在预览后有变化,已重新预览"); runPreview(target, mode); } else setErr(filesErrorText(e)); }, @@ -432,8 +540,8 @@ export function TransferDialog({ {mode === "move" ? "移动到…" : "复制到…"} - {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · - 先预览路径、冲突与空间,确认后执行。跨卷按复制 → 核验 → 删源执行。 + {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览再执行。跨卷移动按复制 + → 核验 → 删源。

@@ -459,19 +567,16 @@ export function TransferDialog({ ))}
-
- r.writable && r.health !== "offline").map((r) => r.id)} - onSelect={(loc) => { - setTarget(loc); - runPreview(loc, mode); - }} - /> -
-

只有可写的库根能作为目标。

+ { + setTarget(loc); + runPreview(loc, mode); + }} + className="max-h-72 md:max-h-96" + /> +

只能选可写库根下的目录。

{target && targetRoot ? ( @@ -487,7 +592,7 @@ export function TransferDialog({ )} {dry.isPending ? (

- 生成预览… + 生成预览

) : null} {err ? {err} : null} @@ -508,7 +613,7 @@ export function TransferDialog({
{hnrBlocked ? ( - {plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 + {plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 @@ -650,7 +755,7 @@ export function DeleteDialog({ reason === "permanent_disabled" ? "设置里未开启永久删除(files.allowPermanentDelete)" : reason === "approval_required" - ? "用 API Key 删除,每次都需要审批" + ? "用 API Key 删除,每次都要审批" : filesErrorText(e), ); }, @@ -821,7 +926,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry const rows = [ { label: "本机路径", value: local }, { label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null }, - { label: "根内相对路径", value: entry.path || "(根)" }, + { label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" }, ]; const copy = async (v: string) => { try { @@ -864,58 +969,87 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry ); } -// ─── 刮削 ─────────────────────────────────────────────────────── +// ─── 刷新元数据(MP 的"刮削") ───────────────────────────────────── -export function ScrapeDialog({ entry, onClose }: { entry: FileEntry; onClose: () => void }) { - const [writeNfo, setWriteNfo] = useState(false); - const [images, setImages] = useState(false); - const scrape = useScrape(); +/** + * 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。 + * NFO / 图片投影还没实现,不显示这两项。 + */ +export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) { + const batch = useScrapeBatch(); const key = useMemo(() => newIdempotencyKey(), []); - const asset = entry.asset; - if (!asset) return null; + const [result, setResult] = useState(null); + const entities = [...new Map(items.filter((e) => e.asset).map((e) => [e.asset?.mediaEntityId, e.asset])).values()]; + const single = entities.length === 1 ? entities[0] : null; const run = () => - scrape.mutate( - { idempotencyKey: key, mediaEntityId: asset.mediaEntityId, writeNfo, images }, + batch.mutate( + { idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) }, { 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(); + setResult(r); + (r.skipped.length ? toast.warning : toast.success)( + `已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`, + ); }, - onError: (e) => toast.error(filesErrorText(e, "刮削失败")), + onError: (e) => toast.error(filesErrorText(e, "提交失败")), }, ); return ( !o && onClose()}> - 刮削 {asset.title} - 按字段刷新元数据,锁定的字段和路径不变。不覆盖非本工具生成的 NFO。 + 刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`} + 按字段刷新,锁定的字段和路径不变。即 MP 的“刮削”。 -
- 写 NFO 到绑定目录 - -
-
- 下载海报/背景图 - -
+ {!single && !result ? ( +
    + {entities.map((a) => ( +
  • + {a?.title} + {a?.year ? ` (${a.year})` : ""} +
  • + ))} +
+ ) : null} + {result ? ( +
+ {result.submitted.map((s) => ( +
+

+ {s.title} + + 查看运行 + +

+

+ {s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"} +

+
+ ))} + {result.skipped.map((s) => ( +

+ {s.ref}: + {s.reason === "not_registered" + ? "没有已登记资产" + : s.reason === "no_entity" + ? "不属于任何已登记作品" + : s.reason === "not_found" + ? "作品不存在" + : s.reason} +

+ ))} +
+ ) : null} - + {result ? null : ( + + )}
diff --git a/apps/web/app/components/files/dir-picker.tsx b/apps/web/app/components/files/dir-picker.tsx new file mode 100644 index 00000000..117ac992 --- /dev/null +++ b/apps/web/app/components/files/dir-picker.tsx @@ -0,0 +1,36 @@ +import type { FileRef, FileRoot } from "~/api/files.ts"; +import { cn } from "~/lib/utils.ts"; +import { DirTree } from "./dir-tree.tsx"; + +/** + * 目录选择器(§14 DirPicker):基于 DirTree,只能选可写库根下的目录。 + * 移动/复制到…、回收站"还原到…"、整理向导"指定目录"共用。 + */ +export function DirPicker({ + roots, + value, + onChange, + className, +}: { + roots: FileRoot[]; + value: FileRef | null; + onChange: (ref: FileRef) => void; + className?: string; +}) { + const writable = roots.filter((r) => r.writable && r.health !== "offline"); + return ( +
+ {writable.length ? ( + r.id)} + onSelect={onChange} + /> + ) : ( +

没有可写的库根。在设置里把库根设为纳管后再选。

+ )} +
+ ); +} diff --git a/apps/web/app/components/files/dir-tree.tsx b/apps/web/app/components/files/dir-tree.tsx index 079ca039..d2cfb284 100644 --- a/apps/web/app/components/files/dir-tree.tsx +++ b/apps/web/app/components/files/dir-tree.tsx @@ -3,7 +3,7 @@ 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"; +import { groupRoots, joinRel, segments } from "./model.ts"; type Loc = { root: string; path: string }; type DropHandler = (target: Loc, e: React.DragEvent) => void; @@ -11,7 +11,7 @@ type DropHandler = (target: Loc, e: React.DragEvent) => void; const nodeKey = (root: string, path: string) => `${root}\u0000${path}`; /** - * 根列表 + 懒加载目录树。按 库 / 下载 分组,每根带健康徽标与剩余空间; + * 登记目录 + 懒加载目录树。下载目录在前、媒体库在后,每个登记目录带健康点与剩余空间; * 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。 * pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。 */ @@ -55,13 +55,10 @@ export function DirTree({ 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); + const groups = groupRoots(roots); return ( -