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:
rootandClaude Opus 5.5 committed 2026-09-28 16:26:41 +08:00
1 parent 09a6b0b5d3
commit c31df8b69a
24 files changed
+4820 -1607

No files matched your search

+164 -11
View File
@@ -1,11 +1,14 @@
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
cursorPage,
type FileEntry, type FileEntry,
type FileRef, type FileRef,
FileRootList, FileRootList,
FileStat, FileStat,
type FilesDeleteRequest, type FilesDeleteRequest,
FilesDeleteResult, FilesDeleteResult,
type FilesEntityFromCandidateRequest,
FilesEntityFromCandidateResult,
type FilesIdentifyRequest, type FilesIdentifyRequest,
FilesIdentifyResult, FilesIdentifyResult,
FilesListResponse, FilesListResponse,
@@ -16,16 +19,26 @@ import {
FilesOrganizeResult, FilesOrganizeResult,
type FilesProbeRequest, type FilesProbeRequest,
FilesProbeResult, FilesProbeResult,
FilesRecordSourcesResult,
FilesRecordsResult,
type FilesRenameRequest, type FilesRenameRequest,
FilesRenameResult, FilesRenameResult,
type FilesScrapeBatchRequest,
FilesScrapeBatchResult,
type FilesScrapeRequest, type FilesScrapeRequest,
FilesScrapeResult, FilesScrapeResult,
type FilesSuggestNameRequest,
FilesSuggestNameResult,
FilesTransferResult, FilesTransferResult,
FilesTrashList, FilesTrashList,
type FilesTrashPurgeRequest, type FilesTrashPurgeRequest,
FilesTrashPurgeResult, FilesTrashPurgeResult,
type FilesTrashRestoreRequest, type FilesTrashRestoreRequest,
FilesTrashRestoreResult, FilesTrashRestoreResult,
FilesTreeResult,
LibraryItem,
SearchResponse,
type TmdbCandidate,
} from "@umt/contracts"; } from "@umt/contracts";
import type { z } from "zod"; import type { z } from "zod";
import type { FetcherArg } from "~/lib/api.ts"; import type { FetcherArg } from "~/lib/api.ts";
@@ -39,11 +52,20 @@ export type {
FileEntry, FileEntry,
FileIdentifyCandidate, FileIdentifyCandidate,
FileIdentifyItem, FileIdentifyItem,
FileParsed,
FileRef, FileRef,
FileRoot, FileRoot,
FileStatus, FileStatus,
FilesOnExisting,
FilesOrganizeGroup, FilesOrganizeGroup,
FilesOrganizeItem,
FilesOrganizeRequest,
FilesScrapeBatchResult,
FilesSuggestNameResult,
FilesTransferResult, FilesTransferResult,
FilesTreeResult,
NamingTemplateSource,
TmdbCandidate,
TrashItem, TrashItem,
} from "@umt/contracts"; } from "@umt/contracts";
@@ -126,6 +148,7 @@ export const filesKeys = {
tree: (root: string, path: string) => ["files", "tree", root, path] as const, tree: (root: string, path: string) => ["files", "tree", root, path] as const,
stat: (root: string, path: string) => ["files", "stat", root, path] as const, stat: (root: string, path: string) => ["files", "stat", root, path] as const,
trash: ["files", "trash"] as const, trash: ["files", "trash"] as const,
records: (ids: string[], planIds: string[]) => ["files", "records", ids.join(","), planIds.join(",")] as const,
}; };
export function useFileRoots() { 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) { export function useDirChildren(root: string, path: string, enabled: boolean) {
return useQuery({ return useQuery({
queryKey: filesKeys.tree(root, path), queryKey: filesKeys.tree(root, path),
enabled: enabled && !!root, enabled: enabled && !!root,
staleTime: 30_000, staleTime: 30_000,
retry: false, retry: false,
queryFn: ({ signal }) => queryFn: ({ signal }) => call(FilesTreeResult, "/files/tree", { params: { root, path }, 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 })),
}); });
} }
@@ -214,22 +233,147 @@ export function useTrash(enabled = true) {
} }
/** 非媒体小文件(NFO/字幕/图片 ≤ 20 MB)下载与预览地址 */ /** 非媒体小文件(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 }); const qs = new URLSearchParams({ root, path });
if (inline) qs.set("inline", "true");
return `/api/v1/files/download?${qs}`; 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) { export function useTextPreview(root: string | null, path: string | null, enabled: boolean) {
return useQuery({ return useQuery({
queryKey: ["files", "preview", root, path], queryKey: ["files", "preview", root, path],
enabled: enabled && !!root && !!path, enabled: enabled && !!root && !!path,
retry: false, retry: false,
queryFn: async ({ signal }) => { queryFn: async ({ signal }): Promise<TextPreview> => {
if (filesMockEnabled()) return (await loadMock()).mockPreview(root ?? "", path ?? ""); if (filesMockEnabled())
return { text: await (await loadMock()).mockPreview(root ?? "", path ?? ""), truncated: false };
const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal }); const res = await fetch(fileDownloadUrl(root ?? "", path ?? ""), { credentials: "include", signal });
if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`); if (!res.ok) throw new ApiRequestError("internal", `预览失败(HTTP ${res.status})`);
const text = await res.text(); return readTextHead(res, TEXT_PREVIEW_BYTES);
return text.length > 64_000 ? `${text.slice(0, 64_000)}\n…(已截断)` : text; },
});
}
/** 只读响应体前 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 = () => export const useScrape = () =>
useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) }); useMutation({ mutationFn: (b: FilesScrapeRequest) => post(FilesScrapeResult, "/files/scrape", b) });
export const useProbe = () => useFilesMutation((b: FilesProbeRequest) => post(FilesProbeResult, "/files/probe", 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 refKey = (r: FileRef) => `${r.root}\u0000${r.path}`;
+232 -98
View File
@@ -5,9 +5,11 @@ import {
FolderPlus, FolderPlus,
LoaderCircle, LoaderCircle,
PenLine, PenLine,
Sparkles, RefreshCw,
Trash2, Trash2,
TriangleAlert, TriangleAlert,
Undo2,
Wand2,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -16,19 +18,22 @@ import {
type FileEntry, type FileEntry,
type FileRef, type FileRef,
type FileRoot, type FileRoot,
type FilesScrapeBatchResult,
type FilesSuggestNameResult,
type FilesTransferResult, type FilesTransferResult,
filesErrorReason, filesErrorReason,
filesErrorText, filesErrorText,
useDelete, useDelete,
useFileStat, useFileStat,
useIdentify,
useMkdir, useMkdir,
useMove, useMove,
useRename, useRename,
useScrape, useScrapeBatch,
useSuggestName,
} from "~/api/files.ts"; } from "~/api/files.ts";
import { FormField } from "~/components/console/form-field.tsx"; import { FormField } from "~/components/console/form-field.tsx";
import { StatusBadge } from "~/components/console/status-badge.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 { Button } from "~/components/ui/button.tsx";
import { import {
Dialog, Dialog,
@@ -41,15 +46,15 @@ import {
import { Input } from "~/components/ui/input.tsx"; import { Input } from "~/components/ui/input.tsx";
import { Switch } from "~/components/ui/switch.tsx"; import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts"; import { cn, formatBytes } from "~/lib/utils.ts";
import { DirTree } from "./dir-tree.tsx"; import { DirPicker } from "./dir-picker.tsx";
import { import {
baseName, baseName,
deleteBlockText, deleteBlockText,
nameProblem, nameProblem,
nameTaken,
newIdempotencyKey, newIdempotencyKey,
splitCommon, splitCommon,
stemRange, stemRange,
suggestName,
} from "./model.ts"; } 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<string, string> = {
identity_uncertain: "识别不可信,没有推荐名",
};
const TEMPLATE_SOURCE: Record<string, string> = { 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 isDir = entry.type === "dir";
const [name, setName] = useState(entry.name); const [name, setName] = useState(entry.name);
const edited = useRef(false);
const [withSidecars, setWithSidecars] = useState(true); const [withSidecars, setWithSidecars] = useState(true);
const [err, setErr] = useState<string | null>(null); const [err, setErr] = useState<string | null>(null);
const [suggested, setSuggested] = useState<FilesSuggestNameResult | null>(null);
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const key = useMemo(() => newIdempotencyKey(), []); const key = useMemo(() => newIdempotencyKey(), []);
const preview = useRename(); const preview = useRename();
const exec = useRename(); const exec = useRename();
const identify = useIdentify(); const suggest = useSuggestName();
const debounced = useDebounced(name.trim(), 350); const debounced = useDebounced(name.trim(), 350);
const problem = nameProblem(name); const problem = nameProblem(name);
const changed = name.trim() !== entry.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; const el = inputRef.current;
if (!el) return; if (!el) return;
el.focus(); el.focus();
const [a, b] = stemRange(entry.name, isDir); const [a, b] = stemRange(v, isDir);
el.setSelectionRange(a, b); requestAnimationFrame(() => el.setSelectionRange(a, b));
}, [entry.name, isDir]); };
// 输入停顿后 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; const previewMutate = preview.mutate;
useEffect(() => { useEffect(() => {
if (!debounced || debounced === entry.name || nameProblem(debounced)) return; 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]); }, [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) => { const submit = (ev: React.FormEvent) => {
ev.preventDefault(); ev.preventDefault();
if (problem) return setErr(problem); if (problem) return setErr(problem);
if (!changed) return onClose(); if (!changed) return onClose();
if (taken) return setErr("同名已存在,不会覆盖");
exec.mutate( exec.mutate(
{ idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false }, { idempotencyKey: key, root: root.id, path: entry.path, newName: name.trim(), withSidecars, dryRun: false },
{ {
onSuccess: (r) => { onSuccess: (r) => {
toast.success( toast.success(
`已改名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`, `已重命名${r.renames.length > 1 ? `(含 ${r.renames.length - 1} 个附属文件)` : ""}`,
r.recordId r.recordId
? { ? {
action: { action: {
@@ -253,9 +312,9 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
const reason = filesErrorReason(e); const reason = filesErrorReason(e);
setErr( setErr(
reason === "exists" reason === "exists"
? "目标名已存在,不会覆盖" ? "同名已存在,不会覆盖"
: reason === "download_active" : reason === "download_active"
? "正在做种,改名会让下载器找不到文件" ? "做种中,改名会让下载器找不到文件"
: filesErrorText(e), : 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 pv = preview.data && preview.variables?.newName === debounced ? preview.data : null;
const pvErr = preview.error && preview.variables?.newName === debounced ? preview.error : null; const pvErr = preview.error && preview.variables?.newName === debounced ? preview.error : null;
const usingSuggestion = !!suggested?.name && name.trim() === suggested.name;
return ( return (
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
@@ -274,37 +334,85 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
<DialogTitle>重命名</DialogTitle> <DialogTitle>重命名</DialogTitle>
<DialogDescription className="break-all">{entry.name}</DialogDescription> <DialogDescription className="break-all">{entry.name}</DialogDescription>
</DialogHeader> </DialogHeader>
<FormField label="新名称" htmlFor="rename-name" error={err ?? (problem && changed ? problem : null)}> <FormField
label="新名称"
htmlFor="rename-name"
error={err ?? (problem && changed ? problem : taken ? "同名已存在,不会覆盖" : null)}
>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
id="rename-name" id="rename-name"
ref={inputRef} ref={inputRef}
value={name} value={name}
aria-invalid={!!err} aria-invalid={!!err || taken}
onChange={(e) => { onChange={(e) => {
setName(e.target.value); setName(e.target.value);
edited.current = true;
setErr(null); setErr(null);
}} }}
className="font-mono" className="font-mono"
/> />
{entry.fileKind === "video" || isDir ? ( {canSuggest ? (
<Button type="button" variant="outline" onClick={suggest} disabled={identify.isPending}> <Button type="button" variant="outline" onClick={() => runSuggest(true)} disabled={suggest.isPending}>
{identify.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />} {suggest.isPending ? <LoaderCircle className="animate-spin" /> : <Wand2 />}
建议名 按模板生成
</Button> </Button>
) : null} ) : null}
</div> </div>
</FormField> </FormField>
{canSuggest ? (
<div className="-mt-2 space-y-1 text-meta text-muted-foreground">
{suggest.isPending && !suggested ? (
<p className="flex items-center gap-1.5">
<LoaderCircle className="h-3 w-3 animate-spin" /> 取推荐名…
</p>
) : suggest.error ? (
<p className="text-destructive">{filesErrorText(suggest.error, "取推荐名失败")}</p>
) : suggested && !suggested.name ? (
<p>{suggestReasonText(suggested.reason)}</p>
) : suggested?.name ? (
<>
<p className="flex flex-wrap items-center gap-x-2">
{suggested.name === entry.name
? "当前名称与命名模板一致"
: usingSuggestion
? "已填入推荐名"
: `推荐名:${suggested.name}`}
{suggested.templateSource
? `· 模板来源:${TEMPLATE_SOURCE[suggested.templateSource] ?? suggested.templateSource}`
: ""}
{usingSuggestion && name.trim() !== entry.name ? (
<button
type="button"
className="inline-flex items-center gap-1 underline underline-offset-2 hover:text-foreground"
onClick={() => {
setName(entry.name);
edited.current = true;
selectStem(entry.name);
}}
>
<Undo2 className="h-3 w-3" />
恢复原名
</button>
) : null}
</p>
{suggested.segments.length > 1 ? (
<PathSegments segments={suggested.segments} className="text-muted-foreground" />
) : null}
</>
) : null}
</div>
) : null}
{!isDir ? ( {!isDir ? (
<div className="flex items-center justify-between gap-3 text-caption"> <div className="flex items-center justify-between gap-3 text-caption">
<span> <span>
同名附属文件一起改 同名附属文件一起改
<span className="block text-meta text-muted-foreground">同 stem 的字幕 / NFO / 图片</span> <span className="block text-meta text-muted-foreground">主干相同的字幕、NFO、图片</span>
</span> </span>
<Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" /> <Switch checked={withSidecars} onCheckedChange={setWithSidecars} aria-label="同名附属文件一起改" />
</div> </div>
) : null} ) : null}
{changed && !problem ? ( {changed && !problem && !taken ? (
<div className="space-y-2"> <div className="space-y-2">
{preview.isPending ? ( {preview.isPending ? (
<p className="flex items-center gap-2 text-meta text-muted-foreground"> <p className="flex items-center gap-2 text-meta text-muted-foreground">
@@ -325,10 +433,10 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
))} ))}
</ul> </ul>
{pv.assetLocations > 0 ? ( {pv.assetLocations > 0 ? (
<Notice tone="neutral">将同步 {pv.assetLocations} 条资产位置,并写一条整理记录。</Notice> <Notice tone="neutral">同步 {pv.assetLocations} 条资产位置,写一条整理记录。</Notice>
) : null} ) : null}
{pv.bindings.length > 0 ? ( {pv.bindings.length > 0 ? (
<Notice tone="warning">这是作品/季的绑定目录。改名后旧绑定标为已替代,新集入库到新目录。</Notice> <Notice tone="warning">这是作品或季的绑定目录。改名后旧绑定标为已替代,之后入库用新目录。</Notice>
) : null} ) : null}
</> </>
) : null} ) : null}
@@ -338,7 +446,7 @@ export function RenameDialog({ root, entry, onClose }: { root: FileRoot; entry:
<Button type="button" variant="outline" onClick={onClose}> <Button type="button" variant="outline" onClick={onClose}>
取消 取消
</Button> </Button>
<Button type="submit" disabled={exec.isPending || !changed || !!problem}> <Button type="submit" disabled={exec.isPending || !changed || !!problem || taken}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : <PenLine />} {exec.isPending ? <LoaderCircle className="animate-spin" /> : <PenLine />}
重命名 重命名
</Button> </Button>
@@ -418,7 +526,7 @@ export function TransferDialog({
onError: (e) => { onError: (e) => {
const reason = filesErrorReason(e); const reason = filesErrorReason(e);
if (reason === "plan_stale") { if (reason === "plan_stale") {
setErr("源文件在预览后有变化,已重新预览。确认后再执行。"); setErr("源文件在预览后有变化,已重新预览");
runPreview(target, mode); runPreview(target, mode);
} else setErr(filesErrorText(e)); } else setErr(filesErrorText(e));
}, },
@@ -432,8 +540,8 @@ export function TransferDialog({
<DialogHeader> <DialogHeader>
<DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle> <DialogTitle>{mode === "move" ? "移动到…" : "复制到…"}</DialogTitle>
<DialogDescription> <DialogDescription>
{items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · {items.length === 1 ? baseName(items[0]?.path ?? "") : `${items.length} 项`} · 先预览再执行。跨卷移动按复制
先预览路径、冲突与空间,确认后执行。跨卷按复制 → 核验 → 删源执行。 → 核验 → 删源。
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]"> <div className="grid min-h-0 gap-4 overflow-y-auto md:grid-cols-[15rem_minmax(0,1fr)]">
@@ -459,19 +567,16 @@ export function TransferDialog({
</button> </button>
))} ))}
</div> </div>
<div className="max-h-72 overflow-y-auto rounded-xl border border-border p-1.5 md:max-h-96"> <DirPicker
<DirTree roots={roots}
roots={roots} value={target}
current={target} onChange={(loc) => {
pickOnly setTarget(loc);
defaultOpen={roots.filter((r) => r.writable && r.health !== "offline").map((r) => r.id)} runPreview(loc, mode);
onSelect={(loc) => { }}
setTarget(loc); className="max-h-72 md:max-h-96"
runPreview(loc, mode); />
}} <p className="text-meta text-muted-foreground">只能选可写库根下的目录。</p>
/>
</div>
<p className="text-meta text-muted-foreground">只有可写的库根能作为目标。</p>
</div> </div>
<div className="min-w-0 space-y-3"> <div className="min-w-0 space-y-3">
{target && targetRoot ? ( {target && targetRoot ? (
@@ -487,7 +592,7 @@ export function TransferDialog({
)} )}
{dry.isPending ? ( {dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground"> <p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览… <LoaderCircle className="h-4 w-4 animate-spin" /> 生成预览
</p> </p>
) : null} ) : null}
{err ? <Notice tone="destructive">{err}</Notice> : null} {err ? <Notice tone="destructive">{err}</Notice> : null}
@@ -508,7 +613,7 @@ export function TransferDialog({
</div> </div>
{hnrBlocked ? ( {hnrBlocked ? (
<Notice tone="destructive"> <Notice tone="destructive">
{plan.hnrBlocks.length} 项仍在做种且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。 {plan.hnrBlocks.length} 项做种中且 H&R 未满足,不能移动。改用复制,或等 H&R 满足后再移动。
<button <button
type="button" type="button"
className="ml-1 underline" className="ml-1 underline"
@@ -559,7 +664,7 @@ export function TransferDialog({
</Button> </Button>
<Button onClick={run} disabled={!canRun || exec.isPending}> <Button onClick={run} disabled={!canRun || exec.isPending}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : mode === "move" ? <FolderInput /> : <Copy />} {exec.isPending ? <LoaderCircle className="animate-spin" /> : mode === "move" ? <FolderInput /> : <Copy />}
确认{mode === "move" ? "移动" : "复制"} {mode === "move" ? "移动" : "复制"}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -650,7 +755,7 @@ export function DeleteDialog({
reason === "permanent_disabled" reason === "permanent_disabled"
? "设置里未开启永久删除(files.allowPermanentDelete)" ? "设置里未开启永久删除(files.allowPermanentDelete)"
: reason === "approval_required" : reason === "approval_required"
? "用 API Key 删除,每次都需要审批" ? "用 API Key 删除,每次都要审批"
: filesErrorText(e), : filesErrorText(e),
); );
}, },
@@ -821,7 +926,7 @@ export function CopyPathDialog({ root, entry, onClose }: { root: FileRoot; entry
const rows = [ const rows = [
{ label: "本机路径", value: local }, { label: "本机路径", value: local },
{ label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null }, { label: "真实路径(解析链接后)", value: stat.data?.realPath ?? null },
{ label: "根内相对路径", value: entry.path || "(根)" }, { label: "登记目录内相对路径", value: entry.path || "(登记目录本身)" },
]; ];
const copy = async (v: string) => { const copy = async (v: string) => {
try { 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); * 刷新元数据(§7.2):只对已登记项;多选时按作品去重(POST /files/scrape-batch)。
const [images, setImages] = useState(false); * NFO / 图片投影还没实现,不显示这两项。
const scrape = useScrape(); */
export function RefreshMetadataDialog({ items, onClose }: { items: FileEntry[]; onClose: () => void }) {
const batch = useScrapeBatch();
const key = useMemo(() => newIdempotencyKey(), []); const key = useMemo(() => newIdempotencyKey(), []);
const asset = entry.asset; const [result, setResult] = useState<FilesScrapeBatchResult | null>(null);
if (!asset) return 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 = () => const run = () =>
scrape.mutate( batch.mutate(
{ idempotencyKey: key, mediaEntityId: asset.mediaEntityId, writeNfo, images }, { idempotencyKey: key, mediaEntityIds: entities.map((a) => a?.mediaEntityId as string) },
{ {
onSuccess: (r) => { onSuccess: (r) => {
const extra = setResult(r);
r.nfo === "not_implemented" || r.images === "not_implemented" (r.skipped.length ? toast.warning : toast.success)(
? "还不支持写 NFO 和图片,只刷新了字段。" `已提交 ${r.submitted.length} 个作品,锁定字段不变${r.skipped.length ? `;${r.skipped.length} 个未提交` : ""}`,
: 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();
}, },
onError: (e) => toast.error(filesErrorText(e, "刮削失败")), onError: (e) => toast.error(filesErrorText(e, "提交失败")),
}, },
); );
return ( return (
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>刮削 {asset.title}</DialogTitle> <DialogTitle>刷新元数据{single ? ` · ${single.title}` : ` · ${entities.length} 个作品`}</DialogTitle>
<DialogDescription>按字段刷新元数据,锁定的字段和路径不变。不覆盖非本工具生成的 NFO。</DialogDescription> <DialogDescription>按字段刷新,锁定的字段和路径不变。即 MP 的“刮削”。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex items-center justify-between gap-3 text-caption"> {!single && !result ? (
写 NFO 到绑定目录 <ul className="max-h-40 space-y-0.5 overflow-auto text-caption">
<Switch checked={writeNfo} onCheckedChange={setWriteNfo} aria-label="写 NFO 到绑定目录" /> {entities.map((a) => (
</div> <li key={a?.mediaEntityId} className="truncate">
<div className="flex items-center justify-between gap-3 text-caption"> {a?.title}
下载海报/背景图 {a?.year ? ` (${a.year})` : ""}
<Switch checked={images} onCheckedChange={setImages} aria-label="下载海报/背景图" /> </li>
</div> ))}
</ul>
) : null}
{result ? (
<div className="space-y-2 text-caption">
{result.submitted.map((s) => (
<div key={s.mediaEntityId} className="rounded-lg border border-border/60 px-3 py-2">
<p className="flex items-center justify-between gap-2">
<span className="truncate">{s.title}</span>
<Link className="shrink-0 text-meta underline" to={`/console/engine/${s.taskId}`}>
查看运行
</Link>
</p>
<p className="text-meta text-muted-foreground">
{s.lockedFields.length ? `锁定字段:${s.lockedFields.join("、")}` : "没有锁定字段"}
</p>
</div>
))}
{result.skipped.map((s) => (
<p key={s.ref} className="text-meta text-warning">
{s.ref}:
{s.reason === "not_registered"
? "没有已登记资产"
: s.reason === "no_entity"
? "不属于任何已登记作品"
: s.reason === "not_found"
? "作品不存在"
: s.reason}
</p>
))}
</div>
) : null}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
取消 {result ? "完成" : "取消"}
</Button>
<Button onClick={run} disabled={scrape.isPending}>
{scrape.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
刮削
</Button> </Button>
{result ? null : (
<Button onClick={run} disabled={batch.isPending || !entities.length}>
{batch.isPending ? <LoaderCircle className="animate-spin" /> : <RefreshCw />}
刷新
</Button>
)}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -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 (
<div className={cn("overflow-y-auto rounded-xl border border-border p-1.5", className)}>
{writable.length ? (
<DirTree
roots={writable}
current={value}
pickOnly
defaultOpen={value ? [value.root] : writable.slice(0, 1).map((r) => r.id)}
onSelect={onChange}
/>
) : (
<p className="px-2 py-3 text-caption text-muted-foreground">没有可写的库根。在设置里把库根设为纳管后再选。</p>
)}
</div>
);
}
+10 -13
View File
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts"; import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { healthLabel } from "~/lib/labels.ts"; import { healthLabel } from "~/lib/labels.ts";
import { cn, formatBytes } from "~/lib/utils.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 Loc = { root: string; path: string };
type DropHandler = (target: Loc, e: React.DragEvent) => void; 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}`; const nodeKey = (root: string, path: string) => `${root}\u0000${path}`;
/** /**
* 根列表 + 懒加载目录树。按 库 / 下载 分组,每根带健康徽标与剩余空间; * 登记目录 + 懒加载目录树。下载目录在前、媒体库在后,每个登记目录带健康点与剩余空间;
* 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。 * 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。
* pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。 * pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。
*/ */
@@ -55,13 +55,10 @@ export function DirTree({
return next; return next;
}); });
const groups = [ const groups = groupRoots(roots);
{ title: "媒体库", items: roots.filter((r) => r.kind === "library") },
{ title: "下载目录", items: roots.filter((r) => r.kind === "download") },
].filter((g) => g.items.length > 0);
return ( return (
<nav aria-label="根与目录" className={cn("space-y-4", className)}> <nav aria-label="登记目录" className={cn("space-y-4", className)}>
{groups.map((g) => ( {groups.map((g) => (
<section key={g.title} aria-label={g.title}> <section key={g.title} aria-label={g.title}>
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase"> <p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
@@ -126,7 +123,7 @@ function RootNode({
if (!open.has(k)) toggle(k); if (!open.has(k)) toggle(k);
}} }}
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined} onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
title={disabled ? (offline ? "根离线" : "只读根,不能作为目标") : root.hostPath} title={disabled ? (offline ? "目录离线" : "只读目录,不能作为目标") : root.hostPath}
> >
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" /> <Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
@@ -150,7 +147,7 @@ function RootNode({
</span> </span>
)} )}
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null} {root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
{!root.writable ? <span>只读</span> : null} {!root.writable && root.kind === "library" ? <span>只读</span> : null}
</span> </span>
</span> </span>
</TreeRow> </TreeRow>
@@ -229,8 +226,8 @@ function Children({
depth={depth} depth={depth}
active={active} active={active}
disabled={disabled} disabled={disabled}
expandable expandable={d.hasChildren}
expanded={isOpen} expanded={isOpen && d.hasChildren}
onToggle={() => toggle(k)} onToggle={() => toggle(k)}
onClick={() => !disabled && onSelect({ root: root.id, path: p })} onClick={() => !disabled && onSelect({ root: root.id, path: p })}
onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined} onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined}
@@ -243,7 +240,7 @@ function Children({
)} )}
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span> <span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
</TreeRow> </TreeRow>
{isOpen ? ( {isOpen && d.hasChildren ? (
<Children <Children
root={root} root={root}
path={p} path={p}
@@ -261,7 +258,7 @@ function Children({
})} })}
{q.data?.truncated ? ( {q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}> <p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
只显示前 1000 个子目录 子目录过多,只列出一部分
</p> </p>
) : null} ) : null}
</div> </div>
+81 -17
View File
@@ -12,28 +12,89 @@ import {
useProbe, useProbe,
useTextPreview, useTextPreview,
} from "~/api/files.ts"; } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE, useRecordsForEntity } from "~/api/records.ts";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx"; import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
import { LoadingState } from "~/components/console/empty-state.tsx"; import { LoadingState } from "~/components/console/empty-state.tsx";
import { KvList } from "~/components/console/kv-list.tsx"; import { KvList } from "~/components/console/kv-list.tsx";
import { Timeline } from "~/components/console/timeline.tsx";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { Button, buttonVariants } from "~/components/ui/button.tsx"; import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { downloadLabel } from "~/lib/labels.ts"; import { downloadLabel } from "~/lib/labels.ts";
import { formatBytes, formatDateTime } from "~/lib/utils.ts"; import { formatBytes, formatDateTime } from "~/lib/utils.ts";
import { EntryStatus } from "./file-list.tsx"; import { EntryStatus } from "./file-list.tsx";
import { IdentifyPanel } from "./identify-panel.tsx";
import { baseName } from "./model.ts"; import { baseName } from "./model.ts";
const PREVIEW_MAX = 20 * 1024 * 1024; const PREVIEW_MAX = 20 * 1024 * 1024;
/** 选中单项的详情抽屉:文件信息、作品/Slot/下载链接、sidecars、探测摘要、路径绑定与小文件预览 */ const ACTION_LABEL: Record<string, string> = {
copy: "复制",
move: "移动",
rename: "重命名",
relocate: "目录调整",
hardlink: "硬链接",
trash: "移入回收站",
delete: "永久删除",
restore: "从回收站还原",
};
/** 抽屉"整理记录"节:已登记作品最近 5 条整理记录 */
function RecordsSection({ entityId }: { entityId: string }) {
const q = useRecordsForEntity(entityId);
const items = q.data?.items.slice(0, 5) ?? [];
return (
<DetailSection
title="整理记录"
actions={
<Link className="text-meta text-muted-foreground underline" to={`/console/organize?mediaEntityId=${entityId}`}>
查看整理记录
</Link>
}
>
{q.isPending ? (
<LoadingState rows={2} />
) : q.error ? (
<p className="text-caption text-destructive">读取整理记录失败</p>
) : !items.length ? (
<p className="text-caption text-muted-foreground">这个作品还没有整理记录。</p>
) : (
<Timeline
items={items.map((r) => ({
id: r.id,
at: r.finishedAt ?? r.startedAt ?? r.createdAt,
tone: RECORD_STATUS_TONE[r.status],
title: (
<Link className="hover:underline" to={`/console/organize/${r.id}`}>
{ACTION_LABEL[r.action] ?? r.action} · {RECORD_STATUS_LABEL[r.status]}
</Link>
),
description: r.target.path ? <span className="font-mono text-meta break-all">{r.target.path}</span> : null,
meta: `${r.counts.succeeded}/${r.counts.total}`,
}))}
/>
)}
</DetailSection>
);
}
/** 选中单项的详情抽屉:文件信息、作品/版本目标/下载任务、识别、整理记录、附属文件、媒体信息、路径绑定与小文件预览 */
export function FileDetail({ export function FileDetail({
root, root,
entry, entry,
onClose, onClose,
actions, actions,
identifyAuto,
onOrganize,
}: { }: {
root: FileRoot; root: FileRoot;
entry: FileEntry; entry: FileEntry;
onClose: () => void; onClose: () => void;
actions?: ReactNode; actions?: ReactNode;
/** 从菜单"识别"进入:自动识别一次 */
identifyAuto?: boolean;
/** 识别候选"按此整理" */
onOrganize?: (preset: WizardEntity | null) => void;
}) { }) {
const stat = useFileStat(root.id, entry.path); const stat = useFileStat(root.id, entry.path);
const probe = useProbe(); const probe = useProbe();
@@ -102,12 +163,12 @@ export function FileDetail({
</Link> </Link>
), ),
}, },
{ label: "内容单元", value: e.asset.contentUnit ?? "整部" }, { label: "季集", value: e.asset.contentUnit ?? "整部" },
{ {
label: "Slot", label: "版本目标",
value: e.asset.slotId ? ( value: e.asset.slotId ? (
<Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}> <Link className="underline underline-offset-2" to={`/console/library/${e.asset.mediaEntityId}#slots`}>
{e.asset.slotStatus ?? "查看"} {e.asset.slotStatus ? (SLOT_STATUS[e.asset.slotStatus]?.label ?? e.asset.slotStatus) : "查看"}
</Link> </Link>
) : ( ) : (
"未满足任何版本目标" "未满足任何版本目标"
@@ -116,15 +177,15 @@ export function FileDetail({
{ label: "主资产", value: e.asset.isPrimary ? "是" : "否" }, { label: "主资产", value: e.asset.isPrimary ? "是" : "否" },
]} ]}
/> />
<Link
className="mt-2 inline-block text-meta text-muted-foreground underline"
to={`/console/organize?mediaEntityId=${e.asset.mediaEntityId}`}
>
在整理记录中查看
</Link>
</DetailSection> </DetailSection>
) : null} ) : null}
{e.asset ? <RecordsSection entityId={e.asset.mediaEntityId} /> : null}
{!e.escape && (e.type === "dir" || (e.type === "file" && e.fileKind === "video")) ? (
<IdentifyPanel root={root} entry={e} autoRun={identifyAuto} onOrganize={onOrganize} />
) : null}
{e.download ? ( {e.download ? (
<DetailSection title="下载任务"> <DetailSection title="下载任务">
<KvList <KvList
@@ -225,7 +286,7 @@ export function FileDetail({
/> />
) : ( ) : (
<p className="text-caption text-muted-foreground"> <p className="text-caption text-muted-foreground">
未登记文件不自动探测。点“探测”读取 ffprobe 摘要,缓存 24 小时。 未登记文件不自动探测。点“探测”读取 ffprobe 摘要,结果缓存 24 小时。
</p> </p>
)} )}
</DetailSection> </DetailSection>
@@ -251,14 +312,14 @@ export function FileDetail({
filesMockEnabled() ? ( filesMockEnabled() ? (
<p className="text-caption text-muted-foreground">演示数据没有图片内容。</p> <p className="text-caption text-muted-foreground">演示数据没有图片内容。</p>
) : ( ) : (
<a href={fileDownloadUrl(root.id, e.path)} target="_blank" rel="noreferrer" className="block"> <a href={fileDownloadUrl(root.id, e.path, true)} target="_blank" rel="noreferrer" className="block">
<img <img
src={fileDownloadUrl(root.id, e.path)} src={fileDownloadUrl(root.id, e.path, true)}
alt={e.name} alt={e.name}
className="max-h-80 rounded-xl border border-border object-contain" className="max-h-80 rounded-xl border border-border object-contain"
/> />
<span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground"> <span className="mt-1 inline-flex items-center gap-1 text-meta text-muted-foreground">
<ExternalLink className="h-3 w-3" /> 新窗口打开 <ExternalLink className="h-3 w-3" /> 新窗口看原图
</span> </span>
</a> </a>
) )
@@ -267,9 +328,12 @@ export function FileDetail({
) : text.error ? ( ) : text.error ? (
<p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p> <p className="text-caption text-destructive">{filesErrorText(text.error, "预览失败")}</p>
) : ( ) : (
<pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap"> <>
{text.data} <pre className="max-h-80 overflow-auto rounded-xl border border-border bg-foreground/[0.03] p-3 font-mono text-meta whitespace-pre-wrap">
</pre> {text.data?.text}
</pre>
{text.data?.truncated ? <p className="mt-1 text-meta text-muted-foreground">只显示前 256 KB</p> : null}
</>
)} )}
</DetailSection> </DetailSection>
) : null} ) : null}
+136 -41
View File
@@ -5,11 +5,17 @@ import {
FileText, FileText,
FileVideo, FileVideo,
Folder, Folder,
FolderInput,
Library,
Link2Off, Link2Off,
LoaderCircle, LoaderCircle,
type LucideIcon,
MoreHorizontal, MoreHorizontal,
Music, Music,
PenLine,
Sparkles,
Subtitles, Subtitles,
Wand2,
} from "lucide-react"; } from "lucide-react";
import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { type ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Link } from "react-router"; import { Link } from "react-router";
@@ -18,7 +24,26 @@ import { StatusBadge } from "~/components/console/status-badge.tsx";
import { relTime } from "~/components/workbench/release-fields.ts"; import { relTime } from "~/components/workbench/release-fields.ts";
import { cn, formatBytes } from "~/lib/utils.ts"; import { cn, formatBytes } from "~/lib/utils.ts";
import type { MenuAnchor } from "./context-menu.tsx"; import type { MenuAnchor } from "./context-menu.tsx";
import { clickSelect, entryStatusView, moveFocus, type Selection, selectAll, toggleSelect } from "./model.ts"; import {
clickSelect,
entryStatusView,
entrySubline,
hoverOps,
type MenuKey,
moveFocus,
OP_LABEL,
type Selection,
selectAll,
toggleSelect,
} from "./model.ts";
const HOVER_ICON: Partial<Record<MenuKey, LucideIcon>> = {
organize: Wand2,
identify: Sparkles,
rename: PenLine,
move: FolderInput,
openMedia: Library,
};
export const ROW_H = 52; export const ROW_H = 52;
export const CARD_H = 68; export const CARD_H = 68;
@@ -44,35 +69,57 @@ function EntryIcon({ e }: { e: FileEntry }) {
} }
function sizeCell(e: FileEntry): ReactNode { function sizeCell(e: FileEntry): ReactNode {
if (e.type === "dir") { if (e.type === "dir") return null;
const s = e.dirSummary;
if (!s) return <span className="text-muted-foreground">—</span>;
return (
<span className="text-muted-foreground" title="视频数 / 已登记数">
{s.videoCount}
{s.partial ? "+" : ""} 视频 · {s.registeredCount} 登记
</span>
);
}
return formatBytes(e.size, 1); return formatBytes(e.size, 1);
} }
/** 名称副行:已登记 → 作品链接;待识别 → 解析灰字;目录 → 视频/已登记计数 */
function Subline({ e }: { e: FileEntry }) {
const s = entrySubline(e);
if (!s) return null;
if (s.tone === "link" && e.asset)
return (
<Link
to={`/console/library/${e.asset.mediaEntityId}`}
onClick={(ev) => ev.stopPropagation()}
className="block truncate text-meta text-muted-foreground hover:text-foreground hover:underline"
title={s.text}
>
{s.text}
</Link>
);
return (
<span
className={cn(
"block truncate text-meta",
s.tone === "hint" ? "text-muted-foreground/70" : "text-muted-foreground",
)}
title={s.tone === "hint" ? `按文件名解析:${s.text}` : s.text}
>
{s.text}
</span>
);
}
/** 状态徽标 + 链接(作品 / 下载任务) */ /** 状态徽标 + 链接(作品 / 下载任务) */
export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: boolean }) { export function EntryStatus({ e, compact = false }: { e: FileEntry; compact?: boolean }) {
const v = entryStatusView(e); const v = entryStatusView(e);
if (!v) return compact ? null : <span className="text-muted-foreground">—</span>; if (!v) return compact ? null : <span className="text-muted-foreground">—</span>;
const badge = <StatusBadge tone={v.tone}>{v.label}</StatusBadge>;
return ( return (
<span className="inline-flex min-w-0 items-center gap-1.5" title={v.hint}> <span className="inline-flex min-w-0 items-center gap-1.5" title={v.hint}>
<StatusBadge tone={v.tone}>{v.label}</StatusBadge>
{!compact && e.download ? ( {!compact && e.download ? (
<Link <Link
to={`/console/downloads/${e.download.taskId}`} to={`/console/downloads/${e.download.taskId}`}
onClick={(ev) => ev.stopPropagation()} onClick={(ev) => ev.stopPropagation()}
className="truncate text-meta text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" className="min-w-0 truncate hover:underline"
title={`${v.hint ?? v.label} · 打开下载任务`}
> >
任务 {badge}
</Link> </Link>
) : null} ) : (
badge
)}
</span> </span>
); );
} }
@@ -111,6 +158,8 @@ export function FileList({
onMenu, onMenu,
onRename, onRename,
onDelete, onDelete,
onAction,
opState,
onDragRows, onDragRows,
onDropToDir, onDropToDir,
hasMore, hasMore,
@@ -129,6 +178,9 @@ export function FileList({
onMenu: (e: FileEntry, at: MenuAnchor) => void; onMenu: (e: FileEntry, at: MenuAnchor) => void;
onRename: (e: FileEntry) => void; onRename: (e: FileEntry) => void;
onDelete: (entries: FileEntry[]) => void; onDelete: (entries: FileEntry[]) => void;
/** 行内 hover 操作(桌面);opState 返回禁用原因 */
onAction?: (e: FileEntry, op: MenuKey) => void;
opState?: (e: FileEntry, op: MenuKey) => string | null;
onDragRows: (e: FileEntry, ev: React.DragEvent) => void; onDragRows: (e: FileEntry, ev: React.DragEvent) => void;
onDropToDir: (dir: FileEntry, ev: React.DragEvent) => void; onDropToDir: (dir: FileEntry, ev: React.DragEvent) => void;
hasMore: boolean; hasMore: boolean;
@@ -331,9 +383,6 @@ export function FileList({
</Cell> </Cell>
<Cell header>名称</Cell> <Cell header>名称</Cell>
<Cell header>状态</Cell> <Cell header>状态</Cell>
<Cell header className="hidden xl:block">
季集
</Cell>
<Cell header className="text-right"> <Cell header className="text-right">
大小 大小
</Cell> </Cell>
@@ -388,7 +437,7 @@ export function FileList({
onTouchMove: touchEnd, onTouchMove: touchEnd,
style: { position: "absolute" as const, top: i * rowH, left: 0, right: 0, height: rowH }, style: { position: "absolute" as const, top: i * rowH, left: 0, right: 0, height: rowH },
className: cn( className: cn(
"cursor-default select-none border-b border-border/50 transition-colors duration-micro", "group/row cursor-default select-none border-b border-border/50 transition-colors duration-micro",
selected ? "bg-foreground/[0.07]" : "hover:bg-foreground/[0.035]", selected ? "bg-foreground/[0.07]" : "hover:bg-foreground/[0.035]",
focused && "ring-1 ring-ring/60 ring-inset", focused && "ring-1 ring-ring/60 ring-inset",
dropOver === k && "bg-foreground/[0.1] ring-2 ring-ring ring-inset", dropOver === k && "bg-foreground/[0.1] ring-2 ring-ring ring-inset",
@@ -425,11 +474,15 @@ export function FileList({
<EntryIcon e={e} /> <EntryIcon e={e} />
)} )}
<Cell as="div" className="min-w-0 flex-1"> <Cell as="div" className="min-w-0 flex-1">
<p className="truncate text-body text-foreground">{e.name}</p> <div className="flex min-w-0 items-center gap-2">
<div className="mt-0.5 flex min-w-0 items-center gap-2 text-meta text-muted-foreground"> <p className="min-w-0 flex-1 truncate text-body text-foreground">{e.name}</p>
<EntryStatus e={e} compact /> <EntryStatus e={e} compact />
<span className="shrink-0">{sizeCell(e)}</span> </div>
<span className="truncate">{relTime(e.mtime)}</span> <div className="mt-0.5 flex min-w-0 items-center gap-2 text-meta text-muted-foreground">
<span className="min-w-0 flex-1">
<Subline e={e} />
</span>
{e.type === "file" ? <span className="shrink-0 tabular-nums">{sizeCell(e)}</span> : null}
</div> </div>
</Cell> </Cell>
{menuBtn} {menuBtn}
@@ -453,28 +506,20 @@ export function FileList({
<span className="block truncate text-foreground" title={e.name}> <span className="block truncate text-foreground" title={e.name}>
{e.name} {e.name}
</span> </span>
{e.asset ? ( <Subline e={e} />
<Link
to={`/console/library/${e.asset.mediaEntityId}`}
onClick={(ev) => ev.stopPropagation()}
className="block truncate text-meta text-muted-foreground hover:text-foreground hover:underline"
>
{e.asset.title}
{e.asset.year ? ` (${e.asset.year})` : ""}
{e.asset.slotId ? " · Slot" : ""}
</Link>
) : null}
</span> </span>
</Cell> </Cell>
<Cell className="min-w-0"> <Cell className="min-w-0 overflow-hidden">
<EntryStatus e={e} /> <EntryStatus e={e} />
</Cell> </Cell>
<Cell className="hidden font-mono text-caption text-muted-foreground xl:block">
{e.asset?.contentUnit ?? ""}
</Cell>
<Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell> <Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell>
<Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell> <Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell>
<Cell className="grid place-items-center">{menuBtn}</Cell> <Cell className="relative grid place-items-center">
{onAction && !e.escape ? (
<HoverOps e={e} focused={focused} onAction={onAction} opState={opState} />
) : null}
{menuBtn}
</Cell>
</div> </div>
</div> </div>
); );
@@ -512,9 +557,59 @@ function Cell({
export const DRAG_MIME = "application/x-umt-files"; export const DRAG_MIME = "application/x-umt-files";
// 列:勾选 | 名称 | 状态 | 季集(xl)| 大小 | 修改时间(lg)| ⋯ // 列(§4.2):勾选 | 名称(副行)| 状态 120px | 大小 | 修改时间(lg)| ⋯(hover 时左侧浮出 2 个主操作)
const DESKTOP_COLS = const DESKTOP_COLS =
"grid-cols-[28px_minmax(0,1fr)_10.5rem_7.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_10.5rem_7.5rem_6.5rem_32px] xl:grid-cols-[28px_minmax(0,1fr)_11rem_5.5rem_7.5rem_6.5rem_32px]"; "grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_32px] lg:grid-cols-[28px_minmax(0,1fr)_120px_5.5rem_6.5rem_32px]";
/** 行内 hover / 聚焦时浮出的 2 个主操作(§5.1),盖在大小/时间列上方 */
function HoverOps({
e,
focused,
onAction,
opState,
}: {
e: FileEntry;
focused: boolean;
onAction: (e: FileEntry, op: MenuKey) => void;
opState?: (e: FileEntry, op: MenuKey) => string | null;
}) {
const ops = hoverOps(e);
return (
<span
className={cn(
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-1 rounded-full bg-background/95 py-0.5 pl-1 opacity-0 shadow-sm ring-1 ring-border backdrop-blur transition-opacity duration-micro",
"group-hover/row:pointer-events-auto group-hover/row:opacity-100",
focused && "pointer-events-auto opacity-100",
)}
>
{ops.map((op) => {
const Icon = HOVER_ICON[op];
const why = opState?.(e, op) ?? null;
return (
<button
key={op}
type="button"
tabIndex={-1}
aria-disabled={!!why || undefined}
title={why ?? OP_LABEL[op].replace("…", "")}
onClick={(ev) => {
ev.stopPropagation();
if (!why) onAction(e, op);
}}
onDoubleClick={(ev) => ev.stopPropagation()}
className={cn(
"inline-flex h-7 items-center gap-1 rounded-full px-2 text-meta whitespace-nowrap",
why ? "cursor-not-allowed text-muted-foreground/50" : "text-foreground hover:bg-foreground/[0.07]",
)}
>
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
{OP_LABEL[op].replace("…", "")}
</button>
);
})}
</span>
);
}
export function Checkbox({ export function Checkbox({
checked, checked,
@@ -0,0 +1,152 @@
import { LoaderCircle, Sparkles, Wand2 } from "lucide-react";
import { useEffect } from "react";
import { Link } from "react-router";
import { type FileEntry, type FileRoot, filesErrorText, useIdentify } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { DetailSection } from "~/components/console/detail-sheet.tsx";
import { KvList } from "~/components/console/kv-list.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { candidateEntity, LEVEL, reasonsText } from "~/components/organize-wizard/entity-picker.tsx";
import type { WizardEntity } from "~/components/organize-wizard/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { contentUnitLabel } from "./model.ts";
const KIND: Record<string, string> = { movie: "电影", series: "剧集", variety: "综艺" };
/**
* 详情抽屉"识别"节(§7.1):解析字段 + Top5 候选(等级、理由),每个候选 `按此整理` 打开向导并预填作品。
* 识别只读,不写库。autoRun:从菜单"识别"进入时自动跑一次。
*/
export function IdentifyPanel({
root,
entry,
autoRun,
onOrganize,
}: {
root: FileRoot;
entry: FileEntry;
autoRun?: boolean;
onOrganize?: (preset: WizardEntity | null) => void;
}) {
const identify = useIdentify();
const mutate = identify.mutate;
// biome-ignore lint/correctness/useExhaustiveDependencies: 换文件或 autoRun 时重跑一次
useEffect(() => {
identify.reset();
if (autoRun) mutate({ items: [{ root: root.id, path: entry.path }], minSizeBytes: 0 });
}, [autoRun, root.id, entry.path]);
const run = () => mutate({ items: [{ root: root.id, path: entry.path }], minSizeBytes: 0 });
const it = identify.data?.items[0];
const isDir = entry.type === "dir";
return (
<DetailSection
title="识别"
actions={
<Button size="xs" variant="outline" onClick={run} disabled={identify.isPending}>
{identify.isPending ? <LoaderCircle className="animate-spin" /> : <Sparkles />}
{identify.data ? "重新识别" : "识别"}
</Button>
}
>
{identify.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-3.5 w-3.5 animate-spin" /> 匹配本地作品与 TMDB…
</p>
) : identify.error ? (
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
) : !identify.data ? (
<p className="text-caption text-muted-foreground">按文件名解析,匹配本地作品和 TMDB。只读,不改文件。</p>
) : !it ? (
<p className="text-caption text-muted-foreground">{isDir ? "目录里没有视频文件。" : "没有识别结果。"}</p>
) : (
<div className="space-y-3">
{isDir && (identify.data.items.length > 1 || identify.data.truncated) ? (
<p className="text-meta text-muted-foreground">
目录里有 {identify.data.items.length}
{identify.data.truncated ? "+" : ""} 个视频,这里只显示第一个。整理时逐项确认。
</p>
) : null}
<KvList
items={[
{ label: "标题", value: it.parsed.title },
{ label: "年份", value: it.parsed.year },
{
label: "季集",
value: it.parsed.season != null ? contentUnitLabel(it.parsed.season, it.parsed.episodes) : null,
},
{ label: "类型", value: it.parsed.mediaKind ? (KIND[it.parsed.mediaKind] ?? it.parsed.mediaKind) : null },
{ label: "分辨率", value: it.parsed.resolution },
{ label: "来源", value: it.parsed.source },
{ label: "发布组", value: it.parsed.releaseGroup },
]}
/>
{it.records?.count ? (
<p className="flex flex-wrap items-center gap-1.5 text-caption">
这个源文件有 {it.records.count} 条整理记录
{it.records.lastStatus ? (
<StatusBadge tone={RECORD_STATUS_TONE[it.records.lastStatus]}>
最近:{RECORD_STATUS_LABEL[it.records.lastStatus]}
</StatusBadge>
) : null}
{it.records.lastRecordId ? (
<Link className="underline underline-offset-2" to={`/console/organize/${it.records.lastRecordId}`}>
查看
</Link>
) : null}
</p>
) : null}
{it.skipped === "below_min_size" ? (
<p className="text-caption text-muted-foreground">低于最小体积,没有查候选。</p>
) : null}
{it.warnings.map((w) => (
<p key={w} className="text-caption text-warning">
{w}
</p>
))}
{it.candidates.length ? (
<ul className="divide-y divide-border/60 rounded-xl border border-border">
{it.candidates.map((c) => {
const e = candidateEntity(c);
return (
<li
key={`${c.provider}-${c.mediaEntityId ?? c.externalId}`}
className="flex items-start gap-2 px-3 py-2"
>
<div className="min-w-0 flex-1">
<p className="flex flex-wrap items-center gap-1.5 text-caption text-foreground">
<span className="truncate">
{c.title}
{c.year ? ` (${c.year})` : ""}
</span>
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
{c.mediaEntityId ? null : <StatusBadge tone="warning">仅 TMDB</StatusBadge>}
</p>
<p className="text-meta text-muted-foreground">
{[
KIND[c.kind] ?? c.kind,
c.externalId ? `${c.provider.toUpperCase()} ${c.externalId}` : null,
reasonsText(c.reasons),
]
.filter(Boolean)
.join(" · ")}
</p>
</div>
{onOrganize && e ? (
<Button size="xs" variant="outline" onClick={() => onOrganize(e)}>
<Wand2 />
按此整理
</Button>
) : null}
</li>
);
})}
</ul>
) : it.skipped ? null : (
<p className="text-caption text-muted-foreground">没有可信候选。整理时可以手动搜索作品。</p>
)}
</div>
)}
</DetailSection>
);
}
+275 -43
View File
@@ -13,6 +13,7 @@ import type {
FilesTransferResult, FilesTransferResult,
TrashItem, TrashItem,
} from "@umt/contracts"; } from "@umt/contracts";
import type { MediaSearchResult } from "~/api/files.ts";
import { ApiRequestError } from "~/api/parse.ts"; import { ApiRequestError } from "~/api/parse.ts";
import type { FetcherArg } from "~/lib/api.ts"; import type { FetcherArg } from "~/lib/api.ts";
@@ -558,74 +559,116 @@ function parseName(name: string) {
} }
const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [ const KNOWN: { match: RegExp; id: string; title: string; year: number; kind: "movie" | "series" }[] = [
{ match: /severance/i, id: "e-sev", title: "人生切割术", year: 2022, kind: "series" },
{ match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" }, { match: /oppenheimer/i, id: "e-opp", title: "奥本海默", year: 2023, kind: "movie" },
{ match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" }, { match: /dune/i, id: "e-dune", title: "沙丘2", year: 2024, kind: "movie" },
{ match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" }, { match: /last of us/i, id: "e-tlou", title: "最后生还者", year: 2023, kind: "series" },
]; ];
/** 本地作品搜索(向导里改选作品) */ /** 作品选择器搜索:本地作品 + 一条 TMDB 候选(演示"将新建作品") */
export function mockEntitySearch(q: string) { export function mockMediaSearch(q: string): MediaSearchResult {
const t = q.trim().toLowerCase(); const t = q.trim().toLowerCase();
return KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({ const local = KNOWN.filter((k) => k.title.includes(t) || k.match.test(t) || k.id.includes(t)).map((k) => ({
id: k.id, id: k.id,
title: k.title, title: k.title,
year: k.year, year: k.year,
kind: k.kind, kind: k.kind,
alias: null,
})); }));
const tmdb = t
? [
{
provider: "tmdb" as const,
entityType: "tv" as const,
externalId: "95396",
title: "人生切割术",
originalTitle: "Severance",
year: 2022,
overview: null,
popularity: null,
localEntityId: null,
},
]
: [];
return { local, tmdb, tmdbConfigured: true };
} }
function identify(items: ItemRef[]): { items: FileIdentifyItem[]; truncated: boolean } { function identify(
items: ItemRef[],
minSize = 0,
): { items: FileIdentifyItem[]; truncated: boolean; excludedBySize: number } {
const out: FileIdentifyItem[] = []; const out: FileIdentifyItem[] = [];
let excludedBySize = 0;
for (const it of items) { for (const it of items) {
const { node } = find(it.root, it.path); const { node } = find(it.root, it.path);
const files = node.dir const files = node.dir
? lazyChildren(node) ? lazyChildren(node)
.filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video") .filter((c) => !c.dir && kindOf(c.name, false).fileKind === "video")
.slice(0, 200) .slice(0, 200)
.map((c) => ({ path: join(it.path, c.name), name: c.name })) .map((c) => ({ path: join(it.path, c.name), name: c.name, size: c.size, meta: c.meta }))
: [{ path: it.path, name: node.name }]; : [{ path: it.path, name: node.name, size: node.size, meta: node.meta }];
for (const fl of files) { for (const fl of files) {
const parsed = parseName(fl.name); const parsed = parseName(fl.name);
const hit = KNOWN.find((k) => k.match.test(parsed.title ?? "")); const small = fl.size < minSize;
const candidates = hit if (small) excludedBySize++;
const hit = small ? undefined : KNOWN.find((k) => k.match.test(parsed.title ?? ""));
const sev = !small && /severance/i.test(parsed.title ?? "");
const candidates = sev
? [ ? [
{
mediaEntityId: hit.id,
provider: "local",
externalId: null,
title: hit.title,
year: hit.year,
kind: hit.kind,
level: "high" as const,
score: 0.94,
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
},
{ {
mediaEntityId: null, mediaEntityId: null,
provider: "tmdb", provider: "tmdb",
externalId: "99999", externalId: "95396",
title: `${parsed.title}(同名旧作)`, title: "人生切割术",
year: (parsed.year ?? hit.year) - 30, year: 2022,
kind: hit.kind, kind: "series" as const,
level: "low" as const, level: "high" as const,
score: 0.31, score: 0.9,
reasons: ["仅标题相似"], reasons: ["TMDB 标题命中", "季集结构一致"],
}, },
] ]
: []; : hit
? [
{
mediaEntityId: hit.id,
provider: "local",
externalId: null,
title: hit.title,
year: hit.year,
kind: hit.kind,
level: "high" as const,
score: 0.94,
reasons: ["本地作品别名命中", parsed.year ? "年份一致" : "季集结构一致"],
},
{
mediaEntityId: null,
provider: "tmdb",
externalId: "99999",
title: `${parsed.title}(同名旧作)`,
year: (parsed.year ?? hit.year) - 30,
kind: hit.kind,
level: "low" as const,
score: 0.31,
reasons: ["仅标题相似"],
},
]
: [];
out.push({ out.push({
root: it.root, root: it.root,
path: fl.path, path: fl.path,
parsed, parsed,
candidates, candidates,
suggestion: { mediaEntityId: hit?.id ?? null, contentUnitId: null, contentUnit: null }, suggestion: { mediaEntityId: hit?.id ?? null, contentUnitId: null, contentUnit: null },
existing: { assetId: null }, existing: { assetId: fl.meta?.asset?.assetId ?? null },
warnings: hit ? [] : ["本地与 TMDB 都没有找到可信候选"], warnings: hit || sev || small ? [] : ["本地与 TMDB 都没有找到可信候选"],
size: fl.size,
skipped: small ? "below_min_size" : null,
records: /Oppenheimer/.test(fl.name)
? { count: 1, lastRecordId: "rec-old-1", lastStatus: "failed" as const }
: { count: 0, lastRecordId: null, lastStatus: null },
}); });
} }
} }
return { items: out, truncated: false }; return { items: out, truncated: false, excludedBySize };
} }
let seq = 0; let seq = 0;
@@ -700,40 +743,114 @@ function doMove(body: {
} }
function organize(body: { function organize(body: {
items: { root: string; path: string; mediaEntityId: string; season?: number; episodes?: number[] }[]; items: {
root: string;
path: string;
mediaEntityId?: string;
external?: { externalId: string; kind: string };
season?: number;
episodes?: number[];
}[];
action: "copy" | "move"; action: "copy" | "move";
dryRun: boolean; dryRun: boolean;
onExisting?: "skip" | "conflict" | "replace_with_verify";
naming?: "template" | "keep";
minSizeBytes?: number;
}): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } { }): { dryRun: boolean; groups: OrganizeGroup[]; recordIds: string[] } {
const byEntity = new Map<string, typeof body.items>(); const byEntity = new Map<string, typeof body.items>();
for (const it of body.items) byEntity.set(it.mediaEntityId, [...(byEntity.get(it.mediaEntityId) ?? []), it]); for (const it of body.items) {
const id = it.mediaEntityId ?? (it.external ? "e-sev" : "?");
byEntity.set(id, [...(byEntity.get(id) ?? []), it]);
}
const groups: OrganizeGroup[] = []; const groups: OrganizeGroup[] = [];
const onExisting = body.onExisting ?? "conflict";
for (const [id, items] of byEntity) { for (const [id, items] of byEntity) {
const k = KNOWN.find((x) => x.id === id); const k =
KNOWN.find((x) => x.id === id) ??
(id === "e-sev" ? { title: "人生切割术", year: 2022, kind: "series" } : undefined);
const tv = k?.kind === "series"; const tv = k?.kind === "series";
const libRoot = tv ? "/mnt/media/tv" : "/mnt/media/movies"; const libRoot = tv ? "/mnt/media/tv" : "/mnt/media/movies";
const dirName = id === "e-tlou" ? "The Last of Us (2023)" : `${k?.title ?? id} (${k?.year ?? ""})`; const dirName = id === "e-tlou" ? "The Last of Us (2023)" : `${k?.title ?? id} (${k?.year ?? ""})`;
const files = items.map((it) => { let conflict = false;
const files = items.flatMap((it) => {
const ext = it.path.split(".").pop(); const ext = it.path.split(".").pop();
const s = it.season != null ? `S${String(it.season).padStart(2, "0")}` : ""; const s = it.season != null ? `S${String(it.season).padStart(2, "0")}` : "";
const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : ""; const e = it.episodes?.length ? `E${String(it.episodes[0]).padStart(2, "0")}` : "";
const name = tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`; const orig = it.path.split("/").pop() ?? "";
const name = body.naming === "keep" ? orig : tv ? `${dirName} - ${s}${e}.${ext}` : `${dirName}.${ext}`;
const dst = tv const dst = tv
? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}` ? `${libRoot}/${dirName}/Season ${String(it.season ?? 1).padStart(2, "0")}/${name}`
: `${libRoot}/${dirName}/${name}`; : `${libRoot}/${dirName}/${name}`;
const r = state.roots.find((x) => x.id === it.root); const r = state.roots.find((x) => x.id === it.root);
const { node } = find(it.root, it.path); const { node, parent } = find(it.root, it.path);
return { source: `${r?.hostPath}/${it.path}`, target: dst, kind: "main", size: node.size }; const exists = id === "e-dune";
if (exists && onExisting === "conflict") conflict = true;
const main = {
source: `${r?.hostPath}/${it.path}`,
target: dst,
kind: "main",
size: node.size,
action: exists
? onExisting === "skip"
? "skip"
: onExisting === "replace_with_verify"
? "replace"
: "hold"
: "transfer",
existing: exists
? onExisting === "skip"
? "skip"
: onExisting === "replace_with_verify"
? "replace"
: "conflict"
: null,
skipReason: exists && onExisting === "skip" ? "target_exists" : null,
} as OrganizeGroup["files"][number];
const stem = orig.replace(/\.[^.]+$/, "");
const dirPath = it.path.split("/").slice(0, -1).join("/");
const subs = (parent?.children ?? [])
.filter((c) => c !== node && !c.dir && c.name.startsWith(stem.slice(0, 16)) && /\.(srt|ass)$/.test(c.name))
.map((c) => ({
source: `${r?.hostPath}/${join(dirPath, c.name)}`,
target: dst.replace(/\.[^.]+$/, `.zh.${c.name.split(".").pop()}`),
kind: "subtitle",
size: c.size,
action: "transfer" as const,
existing: null,
skipReason: null,
}));
return [main, ...subs];
}); });
const segs = tv
? [
{ text: dirName, role: "root" as const, bound: id === "e-tlou" },
{ text: `Season ${String(items[0]?.season ?? 1).padStart(2, "0")}`, role: "season" as const, bound: false },
]
: [{ text: dirName, role: "root" as const, bound: id === "e-dune" }];
groups.push({ groups.push({
mediaEntityId: id, mediaEntityId: id,
contentUnitId: null, contentUnitId: null,
planId: `oplan-${id}`, planId: conflict ? null : `oplan-${id}`,
files, files,
excluded: [],
truncated: false, truncated: false,
recordId: body.dryRun ? null : `rec-${++seq}`, recordId: body.dryRun || conflict ? null : `rec-${++seq}`,
taskId: body.dryRun ? null : `task-${seq}`, taskId: body.dryRun || conflict ? null : `task-${seq}`,
warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [], warnings: items.some((x) => x.season != null) && !tv ? ["电影不使用季/集覆写"] : [],
error: null, error: conflict ? { code: "plan_blocked", message: "目标已存在同名文件,按“交给待处理”整组不执行" } : null,
action: body.action,
entity: items.some((x) => x.external)
? { provider: "tmdb", externalId: "95396", created: true, title: "人生切割术" }
: null,
onExisting,
minSizeBytes: body.minSizeBytes,
naming: {
mode: body.naming ?? "template",
templateSource: body.naming === "keep" ? "override" : "global",
templateVersion: 2,
},
targetSegments: segs,
refreshTaskId: null,
}); });
} }
return { return {
@@ -743,6 +860,29 @@ function organize(body: {
}; };
} }
function tree(params: Record<string, unknown>) {
const root = String(params.root ?? "");
const path = String(params.path ?? "");
const { node } = find(root, path);
const dirs = lazyChildren(node)
.filter((c) => c.dir && !c.name.startsWith("."))
.map((c) => ({
name: c.name,
path: join(path, c.name),
hasChildren: !c.lazyCount && (c.children ?? []).some((x) => x.dir),
}));
const e = toEntry(node, path);
return {
root,
path,
name: node.name || rootOf(root).name,
parent: path ? path.split("/").slice(0, -1).join("/") : null,
dirs,
truncated: false,
summary: e.dirSummary ?? { videoCount: 0, registeredCount: 0, partial: false },
};
}
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms)); const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
/** 与后端同路径同形状的内存实现 */ /** 与后端同路径同形状的内存实现 */
@@ -880,7 +1020,73 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
} }
case "/files/identify": case "/files/identify":
await delay(500); await delay(500);
return identify(b.items); return identify(b.items, b.minSizeBytes ?? 0);
case "/files/tree":
return tree(p);
case "/files/suggest-name": {
const { node } = find(b.root, b.path);
const parsed = parseName(node.name);
const hit = node.meta?.asset ?? KNOWN.find((k) => k.match.test(parsed.title ?? ""));
if (!hit)
return {
name: null,
relativePath: null,
reason: "identity_uncertain",
mediaEntityId: null,
templateSource: null,
segments: [],
};
const title = "title" in hit ? hit.title : "";
const year = hit.year;
const ext = node.dir ? "" : `.${node.name.split(".").pop()}`;
const se =
parsed.season != null
? ` - S${String(parsed.season).padStart(2, "0")}E${String(parsed.episodes[0] ?? 1).padStart(2, "0")}`
: "";
const name = node.dir ? `${title} (${year})` : `${title} (${year})${se}${ext}`;
return {
name,
relativePath: `${title} (${year})/${name}`,
reason: null,
mediaEntityId: "mediaEntityId" in hit ? hit.mediaEntityId : hit.id,
templateSource: "global",
segments: node.dir
? [{ text: name, role: "root" }]
: [
{ text: `${title} (${year})`, role: "root" },
{ text: name, role: "file" },
],
};
}
case "/files/records":
return {
items: String(p.ids ?? "")
.split(",")
.filter(Boolean)
.map((id) => mockRecord(id)),
planRecords: {},
};
case "/files/record-sources":
return {
recordId: p.recordId,
mediaEntityId: "e-opp",
action: "copy",
status: "failed",
items: [{ root: "dl:qb-main:0", path: "Oppenheimer.2023.2160p.BluRay.x265.10bit.HDR-GRP.mkv" }],
unresolved: [],
};
case "/files/scrape-batch": {
const ids: string[] = b.mediaEntityIds ?? [];
return {
submitted: ids.map((id) => ({
mediaEntityId: id,
title: KNOWN.find((k) => k.id === id)?.title ?? id,
taskId: `run-${++seq}`,
lockedFields: ["title"],
})),
skipped: [],
};
}
case "/files/organize": case "/files/organize":
await delay(400); await delay(400);
return organize(b); return organize(b);
@@ -911,6 +1117,32 @@ export async function mockFiles(path: string, arg: FetcherArg): Promise<unknown>
} }
} }
function mockRecord(id: string) {
const t = new Date().toISOString();
return {
id,
taskId: null,
downloadTaskId: null,
operationIds: [],
media: null,
trigger: "manual" as const,
origin: "manual" as const,
actor: { type: "admin" as const, id: "w31-admin" },
action: "copy" as const,
status: "succeeded" as const,
source: { view: "local" as const, path: null },
target: { view: "library" as const, path: null },
reason: null,
counts: { total: 1, succeeded: 1, failed: 0, skipped: 0 },
files: null,
createdAt: t,
updatedAt: t,
startedAt: t,
finishedAt: t,
changeSeq: "1",
};
}
export async function mockPreview(root: string, path: string): Promise<string> { export async function mockPreview(root: string, path: string): Promise<string> {
await delay(100); await delay(100);
if (path.endsWith(".nfo")) if (path.endsWith(".nfo"))
+82 -72
View File
@@ -6,19 +6,24 @@ import {
deleteBlockText, deleteBlockText,
emptySelection, emptySelection,
entryStatusView, entryStatusView,
entrySubline,
formatEpisodes, formatEpisodes,
hoverOps,
initialLocation,
menuModel,
moveFocus, moveFocus,
nameProblem, nameProblem,
nameTaken,
opApplies,
opDisabledReason, opDisabledReason,
parseEpisodes, parseEpisodes,
parseHint,
pruneSelection, pruneSelection,
selectAll, selectAll,
shiftEpisodes, shiftEpisodes,
splitCommon, splitCommon,
stemRange, stemRange,
suggestName,
} from "./model.ts"; } from "./model.ts";
import { rowProblem, rowsFromIdentify, setRowEntity, type WizardRow } from "./organize-wizard.tsx";
const order = ["a", "b", "c", "d", "e"]; const order = ["a", "b", "c", "d", "e"];
@@ -149,87 +154,92 @@ describe("路径与名称", () => {
expect(parseEpisodes("")).toEqual([]); expect(parseEpisodes("")).toEqual([]);
expect(formatEpisodes([5, 3, 4])).toBe("3-5"); expect(formatEpisodes([5, 3, 4])).toBe("3-5");
expect(formatEpisodes([1, 3])).toBe("1,3"); expect(formatEpisodes([1, 3])).toBe("1,3");
expect(shiftEpisodes([1, 2], -1)).toEqual([1, 1]); expect(shiftEpisodes([2, 3], -1)).toEqual([1, 2]);
expect(shiftEpisodes([1, 2], -1)).toBeNull();
expect(shiftEpisodes([1, 2], 12)).toEqual([13, 14]); expect(shiftEpisodes([1, 2], 12)).toEqual([13, 14]);
}); });
it("建议名:电影与剧集", () => { it("同名即时提示忽略自身", () => {
expect( expect(nameTaken("b.mkv", "a.mkv", ["a.mkv", "b.mkv"])).toBe(true);
suggestName({ title: "沙丘2", year: 2024, kind: "movie" }, { season: null, episodes: [] }, "a.mkv", false), expect(nameTaken("a.mkv", "a.mkv", ["a.mkv"])).toBe(false);
).toBe("沙丘2 (2024).mkv"); expect(nameTaken("c.mkv", "a.mkv", ["a.mkv", "b.mkv"])).toBe(false);
expect(
suggestName({ title: "人生切割术", year: 2022, kind: "series" }, { season: 2, episodes: [1] }, "a.mkv", false),
).toBe("人生切割术 (2022) - S02E01.mkv");
expect(suggestName({ title: "A/B", year: null, kind: "movie" }, { season: null, episodes: [] }, "d", true)).toBe(
"A B",
);
}); });
}); });
const item = (path: string, over: Partial<{ id: string | null; level: "high" | "low" }> = {}) => ({ describe("v2 菜单与副行", () => {
root: "lib:x", const lib = { kind: "library" as const, writable: true };
path, const video = {
parsed: { ...base,
title: "T", name: "The.Last.of.Us.S01E02.1080p.WEB-DL.mkv",
year: null, fileKind: "video" as const,
season: 1, status: "unidentified" as const,
episodes: [2], };
resolution: null, const asset = {
source: null, assetId: "a",
releaseGroup: null, locationId: "l",
mediaKind: "series" as const, mediaEntityId: "e",
}, title: "最后生还者",
candidates: year: 2023,
over.id === null contentUnit: "S01E02",
? [] slotId: null,
: [ slotStatus: null,
{ isPrimary: true,
mediaEntityId: over.id ?? "e1", };
provider: "local", const registered = { ...video, status: "registered" as const, asset };
externalId: null, const keys = (xs: ReturnType<typeof menuModel>) => xs.map((x) => ("separator" in x ? "|" : x.key)).join(" ");
title: "T",
year: 2020,
kind: "series" as const,
level: over.level ?? ("high" as const),
score: 1,
reasons: [],
},
],
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: [],
});
describe("整理向导门禁", () => { it("单项菜单:未登记隐藏刷新元数据/打开作品/整理记录,顺序固定", () => {
it("高置信自动选中;低置信/无候选为未决,排除后放行", () => { expect(keys(menuModel([video], lib))).toBe("open | organize identify rename move copy | probe | copyPath | delete");
const rows = rowsFromIdentify( expect(keys(menuModel([registered], lib))).toBe(
[item("d/a.mkv"), item("d/b.mkv", { level: "low" }), item("e/c.mkv", { id: null })], "open | identify rename move copy | scrape probe | copyPath openMedia records | delete",
new Set(),
); );
expect(rows[0]?.entity?.id).toBe("e1"); expect(opApplies("organize", registered)).toBe(false);
expect(rows[0]?.episodes).toBe("2");
expect(rowProblem(rows[0] as WizardRow)).toBeNull();
expect(rowProblem(rows[1] as WizardRow)).toMatch(/作品/);
expect(rowProblem({ ...(rows[2] as WizardRow), excluded: true })).toBeNull();
}); });
it("改作品时同目录未手动改过的行跟随,别的目录不动", () => { it("多选菜单:整理 n 项在前,删除在后;全部已登记才有刷新元数据", () => {
let rows = rowsFromIdentify( expect(keys(menuModel([video, video], lib))).toBe("organize identify move copy probe | delete");
[item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null }), item("e/c.mkv", { id: null })], expect(keys(menuModel([registered, registered], lib))).toContain("scrape");
new Set(),
);
const e = { id: "e9", title: "X", year: 2001, kind: "series" };
rows = setRowEntity(rows, rows[0]?.key as string, e);
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e9", "e9", null]);
rows = setRowEntity(rows, rows[1]?.key as string, { ...e, id: "e8" });
rows = setRowEntity(rows, rows[0]?.key as string, { ...e, id: "e7" });
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e7", "e8", null]);
}); });
it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => { it("被状态阻止的项灰显带原因", () => {
const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"])); const seeding = {
expect(rows[0]?.hnrBlocked).toBe(true); ...video,
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/); status: "seeding" as const,
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/); download: { taskId: "t", status: "completed" as const, progress: 1, hnr: "unsatisfied" as const },
};
const m = menuModel([seeding], lib).find((x) => !("separator" in x) && x.key === "rename");
expect(m && !("separator" in m) ? m.disabled : null).toMatch(/H&R/);
});
it("hover 主操作按状态", () => {
expect(hoverOps(video)).toEqual(["organize", "identify"]);
expect(hoverOps(registered)).toEqual(["rename", "openMedia"]);
expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]);
});
it("副行:已登记作品、待识别解析、目录计数", () => {
expect(entrySubline(registered)?.text).toBe("最后生还者 (2023) · S01E02");
expect(entrySubline(video)).toEqual({ text: "The Last of Us · S01E02 · 1080p", tone: "hint" });
expect(
entrySubline({
...base,
type: "dir" as const,
dirSummary: { videoCount: 13, registeredCount: 0, partial: false },
})?.text,
).toBe("13 视频 · 0 已登记");
expect(parseHint("Oppenheimer.2023.2160p.BluRay.x265.mkv")).toBe("Oppenheimer (2023) · 2160p");
expect(parseHint("readme.txt")).toBeNull();
});
it("初始位置:上次位置 > 下载目录 > 库根;离线根跳过", () => {
const roots = [
{ id: "lib:a", kind: "library" as const, health: "healthy" as const },
{ id: "dl:b", kind: "download" as const, health: "healthy" as const },
{ id: "lib:c", kind: "library" as const, health: "offline" as const },
];
expect(initialLocation(roots, null)).toEqual({ root: "dl:b", path: "" });
expect(initialLocation(roots, { root: "lib:a", path: "x" })).toEqual({ root: "lib:a", path: "x" });
expect(initialLocation(roots, { root: "lib:c", path: "x" })).toEqual({ root: "dl:b", path: "" });
expect(initialLocation([], null)).toBeNull();
}); });
}); });
+212 -34
View File
@@ -8,7 +8,7 @@ export type StatusView = { label: string; tone: StatusTone; hint?: string };
/** 行主状态 → 徽标(文字必带,不只靠颜色) */ /** 行主状态 → 徽标(文字必带,不只靠颜色) */
export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null { export function entryStatusView(e: Pick<FileEntry, "status" | "download" | "escape" | "type">): StatusView | null {
if (e.escape) return { label: "链接逃逸", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" }; if (e.escape) return { label: "链接越界", tone: "destructive", hint: "符号链接指向登记目录之外,所有操作已禁用" };
const s: FileStatus = e.status; const s: FileStatus = e.status;
switch (s) { switch (s) {
case "temp": case "temp":
@@ -65,34 +65,36 @@ export type FileOp =
/** 返回 null = 可用;否则为禁用原因 */ /** 返回 null = 可用;否则为禁用原因 */
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null { export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
if (e.escape && op !== "copyPath") return "链接指向登记目录之外,已禁用"; if (e.escape && op !== "copyPath") return "链接指向登记目录之外";
// 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项) // 下载根不能作为写入目标,但其中的文件可改名/删除/移出(服务端仍按引用检查拒绝做种项)
const rootWritable = root.writable || root.kind === "download"; const rootWritable = root.writable || root.kind === "download";
const seeding = e.status === "seeding" || e.status === "downloading"; const seeding = e.status === "seeding" || e.status === "downloading";
const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading"); const hnrBlocked = e.download && (e.download.hnr !== "satisfied" || e.status === "downloading");
switch (op) { switch (op) {
case "rename": case "rename":
if (!rootWritable) return "只读根,不能改名"; if (!rootWritable) return "只读目录,不能改名";
if (seeding) return "做种/下载中,改名会让下载器找不到文件"; if (seeding) return "做种中,H&R 满足前不能改名";
if (e.status === "temp") return "临时文件不能改名"; if (e.status === "temp") return "临时文件不能改名";
return null; return null;
case "move": case "move":
if (!rootWritable) return "只读根,不能移出。可用“复制到…”"; if (!rootWritable) return "只读目录,不能移出。可以复制";
if (hnrBlocked) return "做种中且 H&R 未满足,不能移动"; if (hnrBlocked) return "做种中,H&R 满足前不能移动";
return null; return null;
case "delete": case "delete":
if (!rootWritable) return "只读根,不能删除"; if (!rootWritable) return "只读目录,不能删除";
if (seeding) return "纳管下载任务仍在使用,不能删除"; if (seeding) return "下载任务仍在使用,不能删除";
return null; return null;
case "organize": case "organize":
case "identify": case "identify":
if (e.status === "temp") return "临时文件不能整理"; if (e.status === "temp") return "临时文件不能整理";
if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录可识别/整理"; if (e.type === "file" && e.fileKind !== "video") return "只有视频或目录能整理";
return null; return null;
case "scrape": case "scrape":
return e.asset ? null : "未登记,先识别并整理入库"; return e.asset ? null : "未登记,先整理入库";
case "probe": case "probe":
return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件"; return e.type === "file" && e.fileKind === "video" ? null : "只能探测视频文件";
case "copy":
return null;
case "openMedia": case "openMedia":
return e.asset ? null : "未关联作品"; return e.asset ? null : "未关联作品";
case "records": case "records":
@@ -220,10 +222,11 @@ export function newIdempotencyKey(): string {
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`; return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
} }
/** 集号偏移:episodes 全体加 n(不小于 1) */ /** 集号偏移:episodes 全体加 n(n 可为负);结果出现小于 1 的集号返回 null(调用方标为问题,不静默截断) */
export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined { export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined | null {
if (!eps?.length) return eps; if (!eps?.length) return eps;
return eps.map((e) => Math.max(1, e + n)); const out = eps.map((e) => e + n);
return out.some((e) => e < 1) ? null : out;
} }
/** "3" / "3-5" / "3,4" → [3] / [3,4,5] / [3,4];非法返回 null */ /** "3" / "3-5" / "3,4" → [3] / [3,4,5] / [3,4];非法返回 null */
@@ -272,41 +275,216 @@ export function splitCommon(a: string, b: string): { common: string; a: string;
return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") }; return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") };
} }
/** 识别建议名:电影 "标题 (年).ext",剧集 "标题 - S01E02.ext" */
export function suggestName(
cand: { title: string; year: number | null; kind: string },
parsed: { season: number | null; episodes: number[] },
currentName: string,
isDir: boolean,
): string {
const ext = isDir ? "" : (/\.[A-Za-z0-9]{1,6}$/.exec(currentName)?.[0] ?? "");
const base = `${cand.title}${cand.year ? ` (${cand.year})` : ""}`.replace(/[/\\:*?"<>|]/g, " ").trim();
if (isDir) return base;
if (cand.kind !== "movie" && parsed.season != null)
return `${base} - ${contentUnitLabel(parsed.season, parsed.episodes)}${ext}`;
return `${base}${ext}`;
}
/** 删除阻断原因文案;可通过勾选解除的返回 soft=true */ /** 删除阻断原因文案;可通过勾选解除的返回 soft=true */
export function deleteBlockText(b: string | null): { text: string; soft: boolean } | null { export function deleteBlockText(b: string | null): { text: string; soft: boolean } | null {
switch (b) { switch (b) {
case null: case null:
return null; return null;
case "download_active": case "download_active":
return { text: "纳管下载任务仍在做种或 H&R 未满足", soft: false }; return { text: "下载任务仍在做种或 H&R 未满足", soft: false };
case "is_root": case "is_root":
return { text: "不能删除根本身", soft: false }; return { text: "不能删除登记目录本身", soft: false };
case "needs_retire": case "needs_retire":
return { text: "含已登记资产,需勾选“同时退役资产”", soft: true }; return { text: "含已登记资产,勾选“同时退役资产”后才能删除", soft: true };
case "needs_unbind": case "needs_unbind":
return { text: "是作品/季的绑定目录,需勾选“解除路径绑定”", soft: true }; return { text: "是作品或季的绑定目录,勾选“解除路径绑定”后才能删除", soft: true };
case "root_unavailable": case "root_unavailable":
return { text: "根离线,不能确认文件状态", soft: false }; return { text: "目录离线,不能确认文件状态", soft: false };
case "not_found": case "not_found":
return { text: "文件已不存在", soft: false }; return { text: "文件已不存在", soft: false };
case "escape": case "escape":
return { text: "链接指向根外", soft: false }; return { text: "链接指向登记目录之外", soft: false };
default: default:
return { text: b, soft: false }; return { text: b, soft: false };
} }
} }
// ─── v2:菜单、行内操作、副行、初始位置 ─────────────────────────────
/** 菜单项键(顺序即规格 §5.2 的最终顺序) */
export type MenuKey =
| "open"
| "organize"
| "identify"
| "rename"
| "move"
| "copy"
| "scrape"
| "probe"
| "copyPath"
| "openMedia"
| "records"
| "delete";
export type MenuModelItem = { key: MenuKey; disabled: string | null } | { key: `sep${number}`; separator: true };
const isVideo = (e: FileEntry) => e.type === "file" && e.fileKind === "video";
const hasRecords = (e: FileEntry) => !!e.asset || e.status === "registered";
/** 不适用的项隐藏(返回 false);被状态阻止的项另由 opDisabledReason 给原因 */
export function opApplies(op: MenuKey, e: FileEntry): boolean {
switch (op) {
case "organize":
return !e.asset && (isVideo(e) || e.type === "dir") && e.status !== "temp";
case "identify":
return isVideo(e) || e.type === "dir";
case "scrape":
case "openMedia":
return !!e.asset;
case "records":
return hasRecords(e);
case "probe":
return isVideo(e);
default:
return true;
}
}
/**
* 单项 / 多选菜单的项与顺序(规格 §5.2):
* 单项:打开 ─ 整理 识别 重命名 移动 复制 ─ 刷新元数据 探测 ─ 复制路径 打开作品 查看整理记录 ─ 删除
* 多选:整理 n 项 识别 移动 复制 探测 刷新元数据 ─ 删除 n 项
*/
export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writable">): MenuModelItem[] {
const multi = list.length > 1;
const why = (op: FileOp) => list.map((x) => opDisabledReason(op, x, root)).find(Boolean) ?? null;
const any = (op: MenuKey) => list.some((x) => opApplies(op, x));
const all = (op: MenuKey) => list.every((x) => opApplies(op, x));
const out: MenuModelItem[] = [];
const sep = () => {
const last = out[out.length - 1];
if (last && !("separator" in last)) out.push({ key: `sep${out.length}`, separator: true });
};
const add = (key: MenuKey, disabled: string | null) => out.push({ key, disabled });
if (!multi) {
const e = list[0] as FileEntry;
add("open", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("organize", e)) add("organize", why("organize"));
if (opApplies("identify", e)) add("identify", why("identify"));
add("rename", why("rename"));
add("move", why("move"));
add("copy", e.escape ? "链接指向登记目录之外" : null);
sep();
if (opApplies("scrape", e)) add("scrape", why("scrape"));
if (opApplies("probe", e)) add("probe", null);
sep();
add("copyPath", null);
if (opApplies("openMedia", e)) add("openMedia", null);
if (opApplies("records", e)) add("records", null);
sep();
add("delete", why("delete"));
return out;
}
if (any("identify"))
add(
"organize",
list
.filter((x) => opApplies("identify", x))
.map((x) => opDisabledReason("organize", x, root))
.find(Boolean) ?? null,
);
if (all("identify")) add("identify", why("identify"));
add("move", why("move"));
add("copy", list.some((x) => x.escape) ? "含指向登记目录之外的链接" : null);
if (any("probe")) add("probe", null);
if (list.every((x) => !!x.asset)) add("scrape", null);
sep();
add("delete", why("delete"));
return out;
}
/** 行内 hover 操作(桌面,§5.1):按状态只露 2 个 */
export function hoverOps(e: FileEntry): [MenuKey, MenuKey] {
if (e.asset) return ["rename", "openMedia"];
if (opApplies("organize", e)) return ["organize", "identify"];
return ["rename", "move"];
}
export const OP_LABEL: Record<MenuKey, string> = {
open: "打开",
organize: "整理…",
identify: "识别",
rename: "重命名",
move: "移动到…",
copy: "复制到…",
scrape: "刷新元数据",
probe: "探测",
copyPath: "复制路径…",
openMedia: "打开作品",
records: "查看整理记录",
delete: "删除",
};
/** 从文件名做轻量解析(只用于待识别行的灰字副行,不查库):标题 · S01E01 · 1080p */
export function parseHint(name: string): string | null {
const base = name.replace(/\.[A-Za-z0-9]{1,5}$/, "");
const se = /\bS(\d{1,2})[ ._-]?E(\d{1,3})(?:-?E?(\d{1,3}))?/i.exec(base);
const season = /\bS(\d{1,2})\b/i.exec(base);
const yr = /[.( [](19\d{2}|20\d{2})(?=[.) \]]|$)/.exec(base);
const res = /\b(2160p|1080p|1080i|720p|4K)\b/i.exec(base);
const cuts = [se?.index, season?.index, yr?.index, res?.index].filter((x): x is number => x != null);
const cut = cuts.length ? Math.min(...cuts) : -1;
if (cut <= 0) return null;
const title = base
.slice(0, cut)
.replace(/[._]+/g, " ")
.replace(/[-([ ]+$/, "")
.trim();
if (!title) return null;
const parts = [title];
if (yr && !se) parts[0] = `${title} (${yr[1]})`;
if (se) {
const s = `S${String(Number(se[1])).padStart(2, "0")}`;
const e1 = `E${String(Number(se[2])).padStart(2, "0")}`;
parts.push(se[3] ? `${s}${e1}-E${String(Number(se[3])).padStart(2, "0")}` : `${s}${e1}`);
} else if (season) parts.push(`S${String(Number(season[1])).padStart(2, "0")}`);
if (res) parts.push((res[1] as string).toLowerCase() === "4k" ? "2160p" : (res[1] as string).toLowerCase());
return parts.join(" · ");
}
/** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 解析灰字;目录 → n 视频 · m 已登记 */
export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint" | "meta" } | null {
if (e.asset) {
const t = `${e.asset.title}${e.asset.year ? ` (${e.asset.year})` : ""}`;
return { text: [t, e.asset.contentUnit].filter(Boolean).join(" · "), tone: "link" };
}
if (e.type === "dir" && e.dirSummary) {
const s = e.dirSummary;
if (!s.videoCount && !s.registeredCount) return null;
return { text: `${s.videoCount}${s.partial ? "+" : ""} 视频 · ${s.registeredCount} 已登记`, tone: "meta" };
}
if (e.type === "file" && e.fileKind === "video" && e.status === "unidentified") {
const h = parseHint(e.name);
return h ? { text: h, tone: "hint" } : null;
}
return null;
}
export type Loc = { root: string; path: string };
/** 初始位置(§2 #2):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */
export function initialLocation(roots: Pick<FileRoot, "id" | "kind" | "health">[], last: Loc | null): Loc | null {
if (!roots.length) return null;
const online = roots.filter((r) => r.health !== "offline");
if (last && online.some((r) => r.id === last.root)) return last;
const pick = online.find((r) => r.kind === "download") ?? online.find((r) => r.kind === "library") ?? roots[0];
return pick ? { root: pick.id, path: "" } : null;
}
/** 登记目录分组顺序:下载目录在前(整理从下载开始),媒体库在后 */
export function groupRoots<T extends Pick<FileRoot, "kind">>(
roots: T[],
): { title: string; kind: string; items: T[] }[] {
return [
{ title: "下载目录", kind: "download", items: roots.filter((r) => r.kind === "download") },
{ title: "媒体库", kind: "library", items: roots.filter((r) => r.kind === "library") },
].filter((g) => g.items.length > 0);
}
/** 目标名在当前目录已存在(忽略自身;不区分大小写只作提示,服务端仍按字节判断) */
export function nameTaken(name: string, self: string, siblings: string[]): boolean {
const n = name.trim();
if (!n || n === self) return false;
return siblings.some((s) => s !== self && s === n);
}
@@ -1,852 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Check,
ChevronDown,
FileStack,
LoaderCircle,
Search,
TriangleAlert,
Undo2,
X,
} from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
type FileEntry,
type FileIdentifyCandidate,
type FileIdentifyItem,
type FileRoot,
type FilesOrganizeGroup,
filesErrorText,
filesMockEnabled,
useIdentify,
useOrganizeFiles,
} from "~/api/files.ts";
import { useEntitySearch } from "~/api/records.ts";
import { useSlots } from "~/api/slots.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { PathPair } from "./dialogs.tsx";
import {
baseName,
contentUnitLabel,
formatEpisodes,
newIdempotencyKey,
parentRel,
parseEpisodes,
shiftEpisodes,
} from "./model.ts";
type Entity = { id: string; title: string; year: number | null; kind: string };
export type WizardRow = {
key: string;
root: string;
path: string;
item: FileIdentifyItem;
entity: Entity | null;
/** 用户手动改过作品(同组联动时不覆盖) */
touched: boolean;
season: string;
episodes: string;
slotId: string;
excluded: boolean;
/** 同目录分组键 */
group: string;
hnrBlocked: boolean;
};
const LEVEL: Record<string, { label: string; tone: "success" | "warning" | "neutral" }> = {
high: { label: "高", tone: "success" },
medium: { label: "中", tone: "neutral" },
low: { label: "低", tone: "warning" },
};
const STEPS = ["识别", "选项", "预览", "执行"] as const;
/** 行是否仍未决(必须处理或排除才能下一步) */
export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
if (parseEpisodes(r.episodes) === null) return "集号格式不对(如 3、3-5、3,4)";
if (r.season && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (r.entity.kind !== "movie" && !r.season.trim()) return "剧集需要季号";
return null;
}
export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string>, rootKey = ""): WizardRow[] {
return items.map((it) => {
const top = it.candidates[0];
const auto =
top?.mediaEntityId && (top.level === "high" || it.suggestion.mediaEntityId === top.mediaEntityId)
? { id: top.mediaEntityId, title: top.title, year: top.year, kind: top.kind }
: null;
return {
key: `${it.root}\u0000${it.path}${rootKey}`,
root: it.root,
path: it.path,
item: it,
entity: auto,
touched: false,
season: it.parsed.season != null ? String(it.parsed.season) : "",
episodes: formatEpisodes(it.parsed.episodes),
slotId: "",
excluded: false,
group: `${it.root}\u0000${parentRel(it.path)}`,
hnrBlocked: [...hnrPaths].some((p) => it.path === p || it.path.startsWith(`${p}/`)),
};
});
}
/** 改一行的作品:同组中未手动改过的行跟随 */
export function setRowEntity(rows: WizardRow[], key: string, entity: Entity | null, propagate = true): WizardRow[] {
const target = rows.find((r) => r.key === key);
if (!target) return rows;
return rows.map((r) => {
if (r.key === key) return { ...r, entity, touched: true };
if (propagate && r.group === target.group && !r.touched && !r.excluded) return { ...r, entity };
return r;
});
}
/**
* 批量整理向导(全高 Sheet,4 步):识别 → 选项 → dryRun 预览 → 执行。
* 逐项可改作品 / 季 / 集 / Slot / 排除;按作品分组预览,单组失败不影响其他组。
*/
export function OrganizeWizard({
roots,
entries,
rootId,
onClose,
}: {
roots: FileRoot[];
entries: FileEntry[];
rootId: string;
onClose: () => void;
}) {
const navigate = useNavigate();
const [step, setStep] = useState(0);
const [rows, setRows] = useState<WizardRow[]>([]);
const [action, setAction] = useState<"copy" | "move">("copy");
const [targetRoot, setTargetRoot] = useState<string>("");
const [preview, setPreview] = useState<FilesOrganizeGroup[] | null>(null);
const [result, setResult] = useState<{ groups: FilesOrganizeGroup[]; recordIds: string[] } | null>(null);
const [offset, setOffset] = useState("1");
const identify = useIdentify();
const dry = useOrganizeFiles();
const exec = useOrganizeFiles();
const execKey = useMemo(() => newIdempotencyKey(), []);
const refs = useMemo(() => entries.map((e) => ({ root: rootId, path: e.path })), [entries, rootId]);
const hnrPaths = useMemo(
() =>
new Set(
entries
.filter((e) => e.download && (e.download.hnr !== "satisfied" || e.status === "downloading"))
.map((e) => e.path),
),
[entries],
);
const identifyMutate = identify.mutate;
useEffect(() => {
identifyMutate(
{ items: refs },
{
onSuccess: (r) => setRows(rowsFromIdentify(r.items, hnrPaths)),
onError: (e) => toast.error(filesErrorText(e, "识别失败")),
},
);
}, [identifyMutate, refs, hnrPaths]);
const active = rows.filter((r) => !r.excluded);
const problems = rows.filter((r) => rowProblem(r));
const anyHnr = active.some((r) => r.hnrBlocked);
const entityKinds = new Set(active.map((r) => r.entity?.kind).filter(Boolean));
const libRoots = roots.filter(
(r) =>
r.kind === "library" &&
r.writable &&
r.health !== "offline" &&
(r.mediaKind === "mixed" ||
r.mediaKind === "other" ||
[...entityKinds].every((k) => (k === "movie" ? r.mediaKind === "movie" : r.mediaKind !== "movie"))),
);
const entityTitle = new Map(active.filter((r) => r.entity).map((r) => [r.entity?.id, r.entity as Entity]));
const body = (dryRun: boolean, planIds?: string[]) => ({
idempotencyKey: dryRun ? `${execKey}-dry-${Date.now()}` : execKey,
action,
targetLibraryRootId: targetRoot ? targetRoot.replace(/^lib:/, "") : undefined,
dryRun,
planIds,
items: active.map((r) => ({
root: r.root,
path: r.path,
mediaEntityId: r.entity?.id ?? "",
season: r.season.trim() ? Number(r.season) : undefined,
episodes: parseEpisodes(r.episodes)?.length ? (parseEpisodes(r.episodes) ?? undefined) : undefined,
slotId: r.slotId || undefined,
})),
});
const runPreview = () => {
setPreview(null);
setStep(2);
dry.mutate(body(true), {
onSuccess: (r) => setPreview(r.groups),
onError: (e) => toast.error(filesErrorText(e, "预览失败")),
});
};
const okGroups = preview?.filter((g) => g.planId && !g.error) ?? [];
const runExec = () => {
setStep(3);
exec.mutate(
body(
false,
okGroups.map((g) => g.planId as string),
),
{
onSuccess: (r) => setResult({ groups: r.groups, recordIds: r.recordIds }),
onError: (e) => {
toast.error(filesErrorText(e, "执行失败"));
setStep(2);
},
},
);
};
const update = (key: string, patch: Partial<WizardRow>) =>
setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...patch } : r)));
const applyOffset = () => {
const n = Number(offset);
if (!Number.isFinite(n) || !n) return;
setRows((rs) =>
rs.map((r) => {
if (r.excluded) return r;
const eps = parseEpisodes(r.episodes);
return eps?.length ? { ...r, episodes: formatEpisodes(shiftEpisodes(eps, n)) } : r;
}),
);
};
const recordsHref =
result && result.recordIds.length === 1
? `/console/organize/${result.recordIds[0]}`
: `/console/organize?ids=${result?.recordIds.join(",") ?? ""}`;
return (
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-5xl">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="text-lead">整理向导</SheetTitle>
<SheetDescription>
{entries.length === 1 ? entries[0]?.name : `${entries.length} 项`} · 确认后才执行,结果写入整理记录。
</SheetDescription>
<ol className="mt-3 flex flex-wrap items-center gap-1.5" aria-label="步骤">
{STEPS.map((s, i) => (
<li key={s} className="flex items-center gap-1.5">
{i > 0 ? <span aria-hidden className="h-px w-4 bg-border" /> : null}
<span
aria-current={step === i ? "step" : undefined}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-meta",
step === i
? "pill-selected text-foreground"
: i < step
? "border-border text-foreground"
: "border-border text-muted-foreground",
)}
>
{i < step ? <Check className="h-3 w-3" /> : <span className="tabular-nums">{i + 1}</span>}
{s}
</span>
</li>
))}
</ol>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{step === 0 ? (
identify.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 识别中:匹配本地作品与 TMDB…
</p>
) : identify.error ? (
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
) : (
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-caption">
<StatusBadge>{rows.length} 个视频</StatusBadge>
{problems.length ? (
<StatusBadge tone="warning">{problems.length} 项待定:选作品或排除</StatusBadge>
) : (
<StatusBadge tone="success">全部已确认</StatusBadge>
)}
{identify.data?.truncated ? (
<StatusBadge tone="warning">目录内视频过多,只取前 200 个</StatusBadge>
) : null}
<span className="ml-auto flex items-center gap-1.5">
集号偏移
<Input
value={offset}
onChange={(e) => setOffset(e.target.value)}
className="h-8 w-16 text-center"
aria-label="集号偏移量"
/>
<Button size="sm" variant="outline" onClick={applyOffset}>
应用到全部
</Button>
</span>
</div>
<ul className="space-y-2">
{rows.map((r) => (
<IdentifyRow
key={r.key}
row={r}
onEntity={(e) => setRows((rs) => setRowEntity(rs, r.key, e))}
onApplyGroup={() =>
r.entity &&
setRows((rs) =>
setRowEntity(
rs.map((x) => (x.group === r.group ? { ...x, touched: false } : x)),
r.key,
r.entity,
),
)
}
groupSize={rows.filter((x) => x.group === r.group).length}
onChange={(p) => update(r.key, p)}
/>
))}
</ul>
</div>
)
) : null}
{step === 1 ? (
<div className="max-w-2xl space-y-5">
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">动作</h3>
<div className="grid gap-2 sm:grid-cols-2">
{(["copy", "move"] as const).map((a) => {
const disabled = a === "move" && anyHnr;
return (
<button
key={a}
type="button"
disabled={disabled}
onClick={() => setAction(a)}
aria-pressed={action === a}
className={cn(
"rounded-xl border px-3 py-2.5 text-left transition-colors duration-micro disabled:cursor-not-allowed disabled:opacity-50",
action === a ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
<span className="block text-body font-medium">{a === "copy" ? "复制" : "移动"}</span>
<span className="block text-meta text-muted-foreground">
{a === "copy"
? "保留源文件,做种不受影响"
: disabled
? "含做种中且 H&R 未满足的文件,不能移动"
: "入库后删源;跨卷按复制 → 核验 → 删源"}
</span>
</button>
);
})}
</div>
</section>
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">目标库根</h3>
<div className="space-y-1.5">
<RootOption
checked={!targetRoot}
onSelect={() => setTargetRoot("")}
title="自动"
hint="按作品已有路径绑定或类型选择"
/>
{libRoots.map((r) => (
<RootOption
key={r.id}
checked={targetRoot === r.id}
onSelect={() => setTargetRoot(r.id)}
title={r.name}
hint={`${r.hostPath}${r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}`}
/>
))}
</div>
</section>
<section className="space-y-1 rounded-xl border border-border px-3 py-2.5 text-caption text-muted-foreground">
<p>命名:按设置里的命名模板;已绑定的作品/季沿用原目录。</p>
<p>目标已存在:不覆盖,作为冲突进入待处理。</p>
</section>
</div>
) : null}
{step === 2 ? (
dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成整理计划…
</p>
) : preview ? (
<div className="space-y-4">
{preview.map((g) => (
<GroupCard
key={`${g.mediaEntityId}-${g.contentUnitId}`}
g={g}
title={entityTitle.get(g.mediaEntityId)}
/>
))}
</div>
) : (
<p className="text-caption text-destructive">{filesErrorText(dry.error, "预览失败")}</p>
)
) : null}
{step === 3 ? (
exec.isPending || !result ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 提交整理任务…
</p>
) : (
<div className="space-y-3">
{result.groups.map((g) => {
const t = entityTitle.get(g.mediaEntityId);
return (
<div
key={`${g.mediaEntityId}-${g.contentUnitId}`}
className="flex flex-wrap items-center gap-2 rounded-xl border border-border px-3 py-2.5"
>
{g.error ? (
<StatusBadge tone="destructive">失败</StatusBadge>
) : g.recordId ? (
<StatusBadge tone="success">已提交</StatusBadge>
) : (
<StatusBadge>未执行</StatusBadge>
)}
<span className="text-body">
{t ? `${t.title}${t.year ? ` (${t.year})` : ""}` : g.mediaEntityId}
</span>
<span className="text-meta text-muted-foreground">{g.files.length} 个文件</span>
{g.error ? <span className="w-full text-meta text-destructive">{g.error.message}</span> : null}
{g.recordId ? (
<Link className="ml-auto text-meta underline" to={`/console/organize/${g.recordId}`}>
整理记录
</Link>
) : null}
</div>
);
})}
<p className="text-caption text-muted-foreground">整理在后台执行,完成后列表自动刷新。</p>
</div>
)
) : null}
</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--shell-border)] px-5 py-3">
<div>
{step > 0 && step < 3 ? (
<Button variant="ghost" onClick={() => setStep(step === 2 ? 0 : step - 1)}>
<ArrowLeft />
{step === 2 ? "返回修改" : "上一步"}
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
{step === 0 ? (
<Button onClick={() => setStep(1)} disabled={!rows.length || problems.length > 0 || active.length === 0}>
下一步
<ArrowRight />
</Button>
) : null}
{step === 1 ? (
<Button onClick={runPreview}>
生成预览
<ArrowRight />
</Button>
) : null}
{step === 2 ? (
<Button onClick={runExec} disabled={!okGroups.length || dry.isPending}>
执行 {okGroups.length} 组
<ArrowRight />
</Button>
) : null}
{step === 3 && result ? (
<>
<Button variant="outline" onClick={onClose}>
关闭
</Button>
<Button
disabled={!result.recordIds.length}
onClick={() => {
onClose();
navigate(recordsHref);
}}
>
<FileStack />
查看整理记录
</Button>
</>
) : null}
</div>
</div>
</SheetContent>
</Sheet>
);
}
function RootOption({
checked,
onSelect,
title,
hint,
}: {
checked: boolean;
onSelect: () => void;
title: string;
hint: string;
}) {
return (
<button
type="button"
aria-pressed={checked}
onClick={onSelect}
className={cn(
"flex w-full items-center gap-3 rounded-xl border px-3 py-2 text-left transition-colors duration-micro",
checked ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
)}
>
<span
className={cn(
"grid h-4 w-4 place-items-center rounded-full border",
checked ? "border-foreground" : "border-border",
)}
>
{checked ? <span className="h-2 w-2 rounded-full bg-foreground" /> : null}
</span>
<span className="min-w-0">
<span className="block text-body">{title}</span>
<span className="block truncate font-mono text-meta text-muted-foreground">{hint}</span>
</span>
</button>
);
}
function GroupCard({ g, title }: { g: FilesOrganizeGroup; title?: Entity }) {
const dirs = [...new Set(g.files.map((f) => (f.target ? parentRel(f.target) : null)).filter(Boolean))];
return (
<section className="rounded-2xl border border-border">
<header className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
<span className="text-body font-medium">
{title ? `${title.title}${title.year ? ` (${title.year})` : ""}` : g.mediaEntityId}
</span>
{g.error ? (
<StatusBadge tone="destructive">不能执行</StatusBadge>
) : (
<StatusBadge tone="success">计划就绪</StatusBadge>
)}
<span className="text-meta text-muted-foreground">
{g.files.length} 个文件 ·{" "}
{formatBytes(
g.files.reduce((s, f) => s + f.size, 0),
1,
)}
</span>
</header>
<div className="space-y-2 px-3 py-2.5">
{g.error ? (
<p className="flex items-start gap-1.5 text-caption text-destructive">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{g.error.message}(回到第 1 步修改后重新预览)
</p>
) : null}
{g.warnings.map((w) => (
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{w}
</p>
))}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
{dirs.length ? (
<p className="text-meta text-muted-foreground">
目标目录:<span className="font-mono">{dirs.join("、")}</span>
</p>
) : null}
<ul className="space-y-1.5">
{g.files.map((f) => (
<li key={f.source} className="rounded-lg border border-border/60 px-2.5 py-1.5">
<div className="mb-0.5 flex items-center gap-1.5 text-meta text-muted-foreground">
<span>{f.kind}</span>
<span>{formatBytes(f.size, 1)}</span>
{!f.target ? <StatusBadge tone="neutral">不入库</StatusBadge> : null}
</div>
<PathPair from={f.source} to={f.target} />
</li>
))}
</ul>
</div>
</section>
);
}
function IdentifyRow({
row,
onEntity,
onApplyGroup,
groupSize,
onChange,
}: {
row: WizardRow;
onEntity: (e: Entity | null) => void;
onApplyGroup: () => void;
groupSize: number;
onChange: (p: Partial<WizardRow>) => void;
}) {
const p = row.item.parsed;
const problem = rowProblem(row);
const isMovie = row.entity?.kind === "movie";
return (
<li
className={cn(
"rounded-xl border px-3 py-2.5",
row.excluded ? "border-border opacity-60" : problem ? "border-warning/50 bg-warning/[0.06]" : "border-border",
)}
>
<div className="flex flex-wrap items-start gap-2">
<div className="min-w-0 flex-1">
<p className="truncate font-mono text-caption text-foreground" title={row.path}>
{baseName(row.path)}
</p>
<p className="text-meta text-muted-foreground">
解析:{p.title ?? "?"}
{p.year ? ` · ${p.year}` : ""}
{p.season != null ? ` · ${contentUnitLabel(p.season, p.episodes)}` : ""}
{p.resolution ? ` · ${p.resolution}` : ""}
{p.source ? ` · ${p.source}` : ""}
{p.releaseGroup ? ` · ${p.releaseGroup}` : ""}
</p>
{row.item.warnings.map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => onChange({ excluded: !row.excluded })}
aria-label={row.excluded ? "恢复该项" : "排除该项"}
>
{row.excluded ? <Undo2 /> : <X />}
{row.excluded ? "恢复" : "排除"}
</Button>
</div>
{!row.excluded ? (
<div className="mt-2 grid gap-2 sm:grid-cols-[minmax(0,1fr)_4.5rem_6.5rem_minmax(0,9rem)]">
<div className="min-w-0">
<EntityPicker candidates={row.item.candidates} value={row.entity} onChange={onEntity} />
{groupSize > 1 && row.entity ? (
<button
type="button"
onClick={onApplyGroup}
className="mt-1 text-meta text-muted-foreground underline hover:text-foreground"
>
应用到同目录 {groupSize} 项
</button>
) : null}
</div>
<Input
aria-label="季"
placeholder="季"
value={row.season}
disabled={isMovie}
onChange={(e) => onChange({ season: e.target.value })}
className="h-9 text-center"
/>
<Input
aria-label="集"
placeholder="集 如 3-5"
value={row.episodes}
disabled={isMovie}
onChange={(e) => onChange({ episodes: e.target.value })}
className="h-9 text-center"
/>
<SlotSelect
entityId={row.entity?.id ?? null}
value={row.slotId}
onChange={(slotId) => onChange({ slotId })}
/>
</div>
) : null}
{problem ? <p className="mt-1.5 text-meta text-warning">{problem}</p> : null}
</li>
);
}
function SlotSelect({
entityId,
value,
onChange,
}: {
entityId: string | null;
value: string;
onChange: (v: string) => void;
}) {
const mock = filesMockEnabled();
const slots = useSlots({ mediaEntityId: entityId ?? undefined }, !!entityId && !mock);
const items = slots.data?.items ?? [];
return (
<select
aria-label="Slot"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={!entityId}
className="h-9 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
>
<option value="">版本目标:自动</option>
{items.map((s) => (
<option key={s.id} value={s.id}>
{s.name}({s.status})
</option>
))}
</select>
);
}
function EntityPicker({
candidates,
value,
onChange,
}: {
candidates: FileIdentifyCandidate[];
value: Entity | null;
onChange: (e: Entity | null) => void;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const mock = filesMockEnabled();
const remote = useEntitySearch(mock ? "" : q);
const mockHits = useQuery({
queryKey: ["files", "mock-entity", q],
enabled: mock && q.trim().length > 0,
queryFn: async () => (await import("./mock.ts")).mockEntitySearch(q),
});
const results: Entity[] = mock
? (mockHits.data ?? [])
: (remote.data?.items ?? []).map((i) => ({
id: i.entity.id,
title: i.entity.title,
year: i.entity.year,
kind: i.entity.kind,
}));
const local = candidates.filter((c) => c.mediaEntityId);
const remoteOnly = candidates.filter((c) => !c.mediaEntityId);
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-9 w-full min-w-0 items-center gap-2 rounded-lg border px-2.5 text-left text-caption",
value ? "border-input" : "border-warning/60 text-warning",
)}
>
<span className="min-w-0 flex-1 truncate">
{value ? `${value.title}${value.year ? ` (${value.year})` : ""}` : "选择作品…"}
</span>
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(26rem,calc(100vw-2rem))] p-2">
{local.length ? (
<p className="px-2 pt-1 pb-1.5 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
候选
</p>
) : null}
<ul className="space-y-0.5">
{local.map((c) => (
<li key={`${c.provider}-${c.mediaEntityId}`}>
<button
type="button"
onClick={() => {
onChange({ id: c.mediaEntityId as string, title: c.title, year: c.year, kind: c.kind });
setOpen(false);
}}
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-foreground/[0.06]"
>
<span className="flex items-center gap-2 text-caption">
{value?.id === c.mediaEntityId ? <Check className="h-3.5 w-3.5" /> : <span className="w-3.5" />}
<span className="min-w-0 flex-1 truncate">
{c.title}
{c.year ? ` (${c.year})` : ""}
</span>
<StatusBadge tone={LEVEL[c.level]?.tone ?? "neutral"}>
置信 {LEVEL[c.level]?.label ?? c.level}
</StatusBadge>
</span>
<span className="block pl-5.5 text-meta text-muted-foreground">{c.reasons.join(" · ")}</span>
</button>
</li>
))}
</ul>
{remoteOnly.length ? (
<p className="px-2 pt-2 text-meta text-muted-foreground">
TMDB 另有 {remoteOnly.length} 个未入库候选(
{remoteOnly.map((c) => `${c.title}${c.year ? ` ${c.year}` : ""}`).join("、")}), 先在“身份与元数据”建立作品。
</p>
) : null}
<div className="relative mt-2">
<Search className="pointer-events-none absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索本地作品"
className="h-8 pl-8 text-caption"
/>
</div>
{results.length ? (
<ul className="mt-1 max-h-48 space-y-0.5 overflow-auto">
{results.map((e) => (
<li key={e.id}>
<button
type="button"
onClick={() => {
onChange(e);
setOpen(false);
}}
className="w-full truncate rounded-lg px-2 py-1.5 text-left text-caption hover:bg-foreground/[0.06]"
>
{e.title}
{e.year ? ` (${e.year})` : ""}{" "}
<span className="text-muted-foreground">· {e.kind === "movie" ? "电影" : "剧集"}</span>
</button>
</li>
))}
</ul>
) : q.trim() ? (
<p className="px-2 py-1.5 text-meta text-muted-foreground">没有匹配的本地作品</p>
) : null}
{value ? (
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="mt-1 w-full rounded-lg px-2 py-1.5 text-left text-meta text-muted-foreground hover:bg-foreground/[0.06]"
>
清除选择
</button>
) : null}
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,44 @@
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "~/components/ui/dialog.tsx";
const KEYS: [string, string][] = [
["↑ ↓ / Home End", "移动焦点(Shift 扩选)"],
["Enter", "打开目录 / 看详情"],
["Backspace / Alt+↑", "上一级"],
["Space", "勾选当前行"],
["Shift+点击", "区间选择"],
["Ctrl/⌘+A", "全选"],
["Ctrl/⌘+Enter", "整理选中项"],
["F2", "重命名"],
["Del", "删除(先进回收站)"],
["/", "搜索当前目录"],
["Esc", "清除选择"],
["Shift+F10 / 菜单键", "打开操作菜单"],
["拖到目录", "移动到(按住 Alt 复制),先出预览"],
["?", "显示这张表"],
];
/** 快捷键浮层(§11,`?` 打开) */
export function ShortcutsDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (o: boolean) => void }) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>快捷键</DialogTitle>
<DialogDescription>焦点在文件列表上时生效。</DialogDescription>
</DialogHeader>
<dl className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-caption">
{KEYS.map(([k, v]) => (
<div key={k} className="contents">
<dt>
<kbd className="rounded-tight border border-border bg-foreground/[0.04] px-1.5 py-0.5 font-mono text-meta">
{k}
</kbd>
</dt>
<dd className="text-muted-foreground">{v}</dd>
</div>
))}
</dl>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,14 @@
import { useEffect, useState } from "react";
/** < md(768px)视为手机布局 */
export function useIsMobile() {
const [m, setM] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(max-width: 767px)");
const on = () => setM(mq.matches);
on();
mq.addEventListener("change", on);
return () => mq.removeEventListener("change", on);
}, []);
return m;
}
@@ -0,0 +1,284 @@
import { Check, ChevronDown, LoaderCircle, Search } from "lucide-react";
import { useState } from "react";
import { type FileIdentifyCandidate, useMediaSearch } from "~/api/files.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Input } from "~/components/ui/input.tsx";
import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover.tsx";
import { cn } from "~/lib/utils.ts";
import { entityKey, entityLabel, type WizardEntity } from "./model.ts";
export const LEVEL: Record<string, { label: string; tone: "success" | "warning" | "neutral" }> = {
high: { label: "高", tone: "success" },
medium: { label: "中", tone: "neutral" },
low: { label: "低", tone: "warning" },
};
const REASON: Record<string, string | null> = {
"title:exact": "标题一致",
"title:alias": "别名一致",
"title:partial": "标题部分一致",
"title:none": "标题不一致",
"year:exact": "年份一致",
"year:season_year": "季年份一致",
"year:within_tolerance": "年份相差 1 年内",
"year:mismatch": "年份不同",
"year:unknown": null,
"type:match": "类型一致",
"type:mismatch": "类型不同",
"type:unknown": null,
explicit_id: "文件名带外部 ID",
local_entity: null,
};
/** 识别理由代码 → 文案;未知与冗余项不显示 */
export function reasonsText(reasons: string[]): string {
return reasons
.map((r) => (r in REASON ? REASON[r] : r))
.filter(Boolean)
.join(" · ");
}
const KIND_LABEL: Record<string, string> = { movie: "电影", series: "剧集", variety: "综艺", tv: "剧集" };
/** 识别候选 → 作品:有本地作品用本地,只有 TMDB 的记为外部候选 */
export function candidateEntity(c: FileIdentifyCandidate): WizardEntity | null {
if (c.mediaEntityId) return { id: c.mediaEntityId, external: null, title: c.title, year: c.year, kind: c.kind };
if (c.provider === "tmdb" && c.externalId && /^\d+$/.test(c.externalId))
return {
id: null,
external: { provider: "tmdb", externalId: c.externalId, kind: c.kind === "movie" ? "movie" : "tv" },
title: c.title,
year: c.year,
kind: c.kind === "movie" ? "movie" : "series",
};
return null;
}
function SectionTitle({ children }: { children: React.ReactNode }) {
return (
<p className="px-2 pt-2 pb-1 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
{children}
</p>
);
}
function Option({
selected,
title,
sub,
badge,
onPick,
}: {
selected: boolean;
title: string;
sub?: string;
badge?: React.ReactNode;
onPick: () => void;
}) {
return (
<li>
<button
type="button"
onClick={onPick}
className="w-full rounded-lg px-2 py-1.5 text-left hover:bg-foreground/[0.06] focus-visible:bg-foreground/[0.06] focus-visible:outline-none"
>
<span className="flex items-center gap-2 text-caption">
{selected ? <Check className="h-3.5 w-3.5 shrink-0" /> : <span className="w-3.5 shrink-0" />}
<span className="min-w-0 flex-1 truncate">{title}</span>
{badge}
</span>
{sub ? <span className="block truncate pl-5.5 text-meta text-muted-foreground">{sub}</span> : null}
</button>
</li>
);
}
/**
* 作品选择器(§14 EntityPicker):上半识别候选(本地 + TMDB),下半搜索本地作品与 TMDB。
* 选 TMDB 条目 = 行标"将新建作品",执行时由后端建作品(G1)。
*/
export function EntityPicker({
candidates = [],
value,
onChange,
placeholder = "选择作品…",
className,
invalid,
}: {
candidates?: FileIdentifyCandidate[];
value: WizardEntity | null;
onChange: (e: WizardEntity | null) => void;
placeholder?: string;
className?: string;
invalid?: boolean;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
const search = useMediaSearch(open ? q : "");
const cands = candidates
.map((c) => ({ c, e: candidateEntity(c) }))
.filter((x): x is { c: FileIdentifyCandidate; e: WizardEntity } => !!x.e);
const local = cands.filter((x) => x.e.id);
const remote = cands.filter((x) => !x.e.id);
const cur = entityKey(value);
const pick = (e: WizardEntity) => {
onChange(e);
setOpen(false);
setQ("");
};
const hits = search.data;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-invalid={invalid || undefined}
className={cn(
"flex h-8 w-full min-w-0 items-center gap-1.5 rounded-lg border px-2.5 text-left text-caption",
value ? "border-input" : invalid ? "border-warning/60 text-warning" : "border-input text-muted-foreground",
className,
)}
>
<span className="min-w-0 flex-1 truncate">{value ? entityLabel(value) : placeholder}</span>
{value && !value.id ? <StatusBadge tone="warning">将新建</StatusBadge> : null}
<ChevronDown className="h-3.5 w-3.5 shrink-0 opacity-60" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[min(28rem,calc(100vw-2rem))] p-1.5">
<div className="max-h-[min(26rem,60dvh)] overflow-y-auto">
{local.length ? (
<>
<SectionTitle>识别候选</SectionTitle>
<ul className="space-y-0.5">
{local.map(({ c, e }) => (
<Option
key={`l-${c.mediaEntityId}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={reasonsText(c.reasons) || undefined}
badge={
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
}
onPick={() => pick(e)}
/>
))}
</ul>
</>
) : null}
{remote.length ? (
<>
<SectionTitle>TMDB 候选 · 选中后执行时新建作品</SectionTitle>
<ul className="space-y-0.5">
{remote.map(({ c, e }) => (
<Option
key={`r-${c.externalId}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={[`TMDB ${c.externalId}`, KIND_LABEL[e.kind], reasonsText(c.reasons)]
.filter(Boolean)
.join(" · ")}
badge={
<StatusBadge tone={LEVEL[c.level]?.tone}>置信 {LEVEL[c.level]?.label ?? c.level}</StatusBadge>
}
onPick={() => pick(e)}
/>
))}
</ul>
</>
) : null}
<div className="relative mt-2 px-1">
<Search className="pointer-events-none absolute top-1/2 left-3.5 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={q}
autoFocus={!cands.length}
onChange={(e) => setQ(e.target.value)}
placeholder="搜索本地作品或 TMDB"
aria-label="搜索作品"
className="h-8 pl-8 text-caption"
/>
{search.isFetching ? (
<LoaderCircle className="absolute top-1/2 right-3.5 h-3.5 w-3.5 -translate-y-1/2 animate-spin text-muted-foreground" />
) : null}
</div>
{q.trim() && hits ? (
<>
{hits.local.length ? <SectionTitle>本地作品</SectionTitle> : null}
<ul className="space-y-0.5">
{hits.local.map((h) => {
const e: WizardEntity = { id: h.id, external: null, title: h.title, year: h.year, kind: h.kind };
return (
<Option
key={`s-${h.id}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={[KIND_LABEL[e.kind], h.alias].filter(Boolean).join(" · ")}
onPick={() => pick(e)}
/>
);
})}
</ul>
{hits.tmdb.filter((t) => !t.localEntityId || !hits.local.some((l) => l.id === t.localEntityId)).length ? (
<SectionTitle>TMDB</SectionTitle>
) : null}
<ul className="space-y-0.5">
{hits.tmdb
.filter((t) => !t.localEntityId || !hits.local.some((l) => l.id === t.localEntityId))
.map((t) => {
const e: WizardEntity = t.localEntityId
? {
id: t.localEntityId,
external: null,
title: t.title,
year: t.year,
kind: t.entityType === "movie" ? "movie" : "series",
}
: {
id: null,
external: { provider: "tmdb", externalId: t.externalId, kind: t.entityType },
title: t.title,
year: t.year,
kind: t.entityType === "movie" ? "movie" : "series",
};
return (
<Option
key={`t-${t.externalId}-${t.entityType}`}
selected={cur === entityKey(e)}
title={entityLabel(e)}
sub={[
`TMDB ${t.externalId}`,
KIND_LABEL[t.entityType],
t.localEntityId ? "已有本地作品" : "选中后新建作品",
t.originalTitle && t.originalTitle !== t.title ? t.originalTitle : null,
]
.filter(Boolean)
.join(" · ")}
onPick={() => pick(e)}
/>
);
})}
</ul>
{!hits.local.length && !hits.tmdb.length && !search.isFetching ? (
<p className="px-2 py-1.5 text-meta text-muted-foreground">
没有匹配的作品{hits.tmdbConfigured ? "" : ";TMDB 未配置,只搜了本地"}
</p>
) : null}
</>
) : null}
{search.error ? <p className="px-2 py-1.5 text-meta text-destructive">搜索失败</p> : null}
{value ? (
<button
type="button"
onClick={() => {
onChange(null);
setOpen(false);
}}
className="mt-1 w-full rounded-lg px-2 py-1.5 text-left text-meta text-muted-foreground hover:bg-foreground/[0.06]"
>
清除选择
</button>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,3 @@
export { EntityPicker } from "./entity-picker.tsx";
export { type OrganizeSource, OrganizeWizard } from "./organize-wizard.tsx";
export { PathSegments } from "./path-segments.tsx";
@@ -0,0 +1,284 @@
import { describe, expect, it } from "vitest";
import type { FileIdentifyItem, FilesOrganizeGroup } from "~/api/files.ts";
import {
applyGroupEntity,
applyMinSize,
applyOffsetTo,
defaultOptions,
displayPath,
downloadContentRef,
groupRows,
MIB,
nestSidecars,
organizeRequest,
pageOf,
previewStats,
rowProblem,
rowsFromIdentify,
setRowEntity,
type WizardEntity,
type WizardRow,
wizardStats,
} from "./model.ts";
const item = (
path: string,
over: Partial<{ id: string | null; level: "high" | "low"; size: number; skipped: "below_min_size" | null }> = {},
): FileIdentifyItem => ({
root: "lib:x",
path,
parsed: {
title: "T",
year: null,
season: 1,
episodes: [2],
resolution: null,
source: null,
releaseGroup: null,
mediaKind: "series",
},
candidates:
over.id === null
? []
: [
{
mediaEntityId: over.id ?? "e1",
provider: "local",
externalId: null,
title: "T",
year: 2020,
kind: "series",
level: over.level ?? "high",
score: 1,
reasons: [],
},
],
suggestion: { mediaEntityId: null, contentUnitId: null, contentUnit: null },
existing: { assetId: null },
warnings: [],
size: over.size ?? 2_000 * MIB,
skipped: over.skipped ?? null,
});
const series: WizardEntity = { id: "e9", external: null, title: "X", year: 2001, kind: "series" };
const tmdbMovie: WizardEntity = {
id: null,
external: { provider: "tmdb", externalId: "872585", kind: "movie" },
title: "奥本海默",
year: 2023,
kind: "movie",
};
describe("识别结果 → 行", () => {
it("高置信自动选中;低置信/无候选为未决,排除后放行", () => {
const rows = rowsFromIdentify(
[item("d/a.mkv"), item("d/b.mkv", { level: "low" }), item("e/c.mkv", { id: null })],
new Set(),
);
expect(rows[0]?.entity?.id).toBe("e1");
expect(rows[0]?.episodes).toBe("2");
expect(rowProblem(rows[0] as WizardRow)).toBeNull();
expect(rowProblem(rows[1] as WizardRow)).toMatch(/作品/);
expect(rowProblem({ ...(rows[2] as WizardRow), excluded: "manual" })).toBeNull();
});
it("服务端按体积跳过的项预先排除并计数", () => {
const rows = rowsFromIdentify(
[item("a.mkv"), item("sample.mkv", { size: 10 * MIB, skipped: "below_min_size" })],
new Set(),
);
expect(rows[1]?.excluded).toBe("below_min_size");
expect(wizardStats(rows)).toMatchObject({ videos: 2, excluded: 1, bySize: 1 });
});
it("改作品时同目录未手动改过的行跟随,别的目录不动", () => {
let rows = rowsFromIdentify(
[item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null }), item("e/c.mkv", { id: null })],
new Set(),
);
rows = setRowEntity(rows, rows[0]?.key as string, series);
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e9", "e9", null]);
rows = setRowEntity(rows, rows[1]?.key as string, { ...series, id: "e8" });
rows = setRowEntity(rows, rows[0]?.key as string, { ...series, id: "e7" });
expect(rows.map((r) => r.entity?.id ?? null)).toEqual(["e7", "e8", null]);
});
it("应用到本组 = 组内第一项的作品复制到全组", () => {
let rows = rowsFromIdentify([item("d/a.mkv", { id: null }), item("d/b.mkv", { id: null })], new Set());
rows = [
{ ...(rows[0] as WizardRow), entity: series, touched: true },
{ ...(rows[1] as WizardRow), touched: true },
];
rows = applyGroupEntity(rows, rows[0]?.group as string);
expect(rows.map((r) => r.entity?.id)).toEqual(["e9", "e9"]);
expect(groupRows(rows)).toHaveLength(1);
});
it("剧集缺季号或集号非法时未决;做种路径标 H&R", () => {
const rows = rowsFromIdentify([item("seed/a.mkv")], new Set(["seed"]));
expect(rows[0]?.hnrBlocked).toBe(true);
expect(rowProblem({ ...(rows[0] as WizardRow), season: "" })).toMatch(/季号/);
expect(rowProblem({ ...(rows[0] as WizardRow), episodes: "1-x" })).toMatch(/集号/);
});
});
describe("批量设置", () => {
it("集号偏移支持负数,小于 1 的行不改并计数", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv")], new Set()).map((r, i) => ({
...r,
episodes: i === 0 ? "13-14" : "1",
}));
const r = applyOffsetTo(rows, new Set(rows.map((x) => x.key)), -12);
expect(r.rows.map((x) => x.episodes)).toEqual(["1-2", "1"]);
expect(r.failed).toBe(1);
});
it("最小体积:低于排除,回到阈值以上取消并要求补识别;手动排除不动", () => {
let rows = rowsFromIdentify(
[
item("big.mkv"),
item("small.mkv", { size: 40 * MIB, skipped: "below_min_size" }),
item("m.mkv", { size: 10 * MIB }),
],
new Set(),
);
rows = rows.map((r) => (r.path === "m.mkv" ? { ...r, excluded: "manual" as const } : r));
const up = applyMinSize(rows, 100 * MIB);
expect(up.rows.map((r) => r.excluded)).toEqual([null, "below_min_size", "manual"]);
const down = applyMinSize(up.rows, 20 * MIB);
expect(down.rows.map((r) => r.excluded)).toEqual([null, null, "manual"]);
expect(down.reidentify.map((r) => r.path)).toEqual(["small.mkv"]);
});
});
describe("请求体", () => {
it("本地作品带 mediaEntityId,TMDB 候选带 external;电影不带季集;排除项不提交", () => {
const rows = rowsFromIdentify([item("a.mkv"), item("b.mkv"), item("c.mkv")], new Set());
const r = [
rows[0] as WizardRow,
{ ...(rows[1] as WizardRow), entity: tmdbMovie },
{ ...(rows[2] as WizardRow), excluded: "manual" as const },
];
const body = organizeRequest(
r,
defaultOptions({ target: { mode: "root", rootId: "lib:0e0e0e0e-0000-0000-0000-000000000000" } }),
{ dryRun: true, idempotencyKey: "k-123456" },
);
expect(body.items).toEqual([
{ root: "lib:x", path: "a.mkv", mediaEntityId: "e1", season: 1, episodes: [2] },
{ root: "lib:x", path: "b.mkv", external: { provider: "tmdb", externalId: "872585", kind: "movie" } },
]);
expect(body.targetLibraryRootId).toBe("0e0e0e0e-0000-0000-0000-000000000000");
expect(body).toMatchObject({
onExisting: "conflict",
naming: "template",
refreshMetadata: true,
minSizeBytes: 50 * MIB,
});
});
it("指定目录写入每项 targetDir", () => {
const rows = rowsFromIdentify([item("a.mkv")], new Set());
const body = organizeRequest(rows, defaultOptions({ target: { mode: "dir", ref: { root: "lib:y", path: "A" } } }), {
dryRun: false,
idempotencyKey: "k-123456",
planIds: ["p"],
});
expect(body.items[0]?.targetDir).toEqual({ root: "lib:y", path: "A" });
expect(body.planIds).toEqual(["p"]);
});
});
const group = (over: Partial<FilesOrganizeGroup> = {}): FilesOrganizeGroup => ({
mediaEntityId: "e1",
contentUnitId: null,
planId: "p1",
files: [
{ source: "/dl/A/a.mkv", target: "/lib/T/a.mkv", kind: "main", size: 10, action: "transfer", existing: null },
{
source: "/dl/A/a.zh.srt",
target: "/lib/T/a.zh.srt",
kind: "subtitle",
size: 1,
action: "transfer",
existing: null,
},
{ source: "/dl/A/b.mkv", target: "/lib/T/b.mkv", kind: "main", size: 10, action: "hold", existing: "conflict" },
],
truncated: false,
recordId: null,
taskId: null,
warnings: [],
error: null,
...over,
});
describe("预览", () => {
it("统计:组、文件、可执行、冲突、失败", () => {
expect(previewStats([group(), group({ planId: null, error: { code: "plan_blocked", message: "x" } })])).toEqual({
groups: 2,
files: 6,
executable: 1,
conflicts: 2,
failed: 1,
});
});
it("全部跳过的组不算可执行", () => {
const skipAll = group({
files: [{ source: "/a.mkv", target: "/l/a.mkv", kind: "main", size: 1, action: "skip", existing: "same" }],
});
expect(previewStats([skipAll]).executable).toBe(0);
});
it("附属文件挂在同主干的主文件下", () => {
const n = nestSidecars(group().files);
expect(n.map((x) => [x.main.source, x.sidecars.length])).toEqual([
["/dl/A/a.mkv", 1],
["/dl/A/b.mkv", 0],
]);
});
it("超过 20 组分页", () => {
const p = pageOf(
Array.from({ length: 45 }, (_, i) => i),
2,
);
expect(p).toMatchObject({ pages: 3, page: 2 });
expect(p.items).toHaveLength(5);
});
it("绝对路径按登记目录显示", () => {
expect(displayPath("/srv/dl/A/a.mkv", [{ hostPath: "/srv/dl", name: "qB" }])).toBe("qB/A/a.mkv");
expect(displayPath("/x/a.mkv", [{ hostPath: "/srv/dl", name: "qB" }])).toBe("/x/a.mkv");
});
});
describe("来源换算", () => {
const roots = [
{ id: "dl:i:0", hostPath: "/srv/downloads" },
{ id: "lib:m", hostPath: "/mnt/media/movies" },
];
it("按下载器路径映射换成本机路径再落到登记目录", () => {
const maps = [
{
viewKind: "downloader",
downloaderInstanceId: "i",
remotePrefix: "/downloads",
localPrefix: "/srv/downloads",
enabled: true,
priority: 0,
},
];
expect(downloadContentRef("/downloads/Movie.2023/", "i", maps, roots)).toEqual({
root: "dl:i:0",
path: "Movie.2023",
});
expect(downloadContentRef("/downloads/Movie.2023", "other", maps, roots)).toBeNull();
});
it("没有映射按同机处理;不在登记目录返回 null", () => {
expect(downloadContentRef("/srv/downloads/a.mkv", "i", [], roots)).toEqual({ root: "dl:i:0", path: "a.mkv" });
expect(downloadContentRef("/tmp/a.mkv", "i", [], roots)).toBeNull();
expect(downloadContentRef(null, "i", [], roots)).toBeNull();
});
});
@@ -0,0 +1,422 @@
// 整理向导的纯逻辑(docs/design/file-manager-v2.md §6):识别结果 → 行、未决判断、批量设置、请求体、预览统计、
// 来源换算(下载任务内容路径 → 登记目录 FileRef)。无 React 依赖,单测见 model.test.ts。
import type {
FileIdentifyItem,
FileRef,
FileRoot,
FilesOnExisting,
FilesOrganizeGroup,
FilesOrganizeItem,
FilesOrganizeRequest,
} from "~/api/files.ts";
import { formatEpisodes, parentRel, parseEpisodes, shiftEpisodes } from "~/components/files/model.ts";
// ─── 作品 ────────────────────────────────────────────────────────
/** 行上选中的作品:本地作品(id)或只在 TMDB 上的候选(external,执行时由后端就地建作品) */
export type WizardEntity = {
id: string | null;
external: { provider: "tmdb"; externalId: string; kind: "movie" | "tv" } | null;
title: string;
year: number | null;
/** movie / series / variety …;TMDB tv 记为 series */
kind: string;
};
export const entityKey = (e: WizardEntity | null) =>
e ? (e.id ?? `tmdb:${e.external?.kind}:${e.external?.externalId}`) : "";
export const entityLabel = (e: Pick<WizardEntity, "title" | "year">) => `${e.title}${e.year ? ` (${e.year})` : ""}`;
export const isMovieKind = (k: string | null | undefined) => k === "movie";
// ─── 行 ──────────────────────────────────────────────────────────
export type ExcludeReason = "manual" | "below_min_size";
export type WizardRow = {
key: string;
root: string;
path: string;
item: FileIdentifyItem;
entity: WizardEntity | null;
/** 用户手动改过作品(同组联动时不覆盖) */
touched: boolean;
season: string;
episodes: string;
slotId: string;
excluded: ExcludeReason | null;
/** 同目录分组键 */
group: string;
hnrBlocked: boolean;
};
/** 行是否仍未决(必须处理或排除才能下一步) */
export function rowProblem(r: WizardRow): string | null {
if (r.excluded) return null;
if (!r.entity) return "未选择作品";
const eps = parseEpisodes(r.episodes);
if (eps === null) return "集号格式不对,如 3、3-5、3,4";
if (r.season.trim() && !/^\d{1,4}$/.test(r.season.trim())) return "季号必须是数字";
if (!isMovieKind(r.entity.kind) && !r.season.trim()) return "剧集需要季号";
return null;
}
/** 识别项 → 行:高置信本地候选(或服务端建议)自动选中;低于最小体积的项预先排除 */
export function rowsFromIdentify(items: FileIdentifyItem[], hnrPaths: Set<string>): WizardRow[] {
return items.map((it) => {
const top = it.candidates[0];
const auto: WizardEntity | null =
top?.mediaEntityId && (top.level === "high" || it.suggestion.mediaEntityId === top.mediaEntityId)
? { id: top.mediaEntityId, external: null, title: top.title, year: top.year, kind: top.kind }
: null;
return {
key: `${it.root}\u0000${it.path}`,
root: it.root,
path: it.path,
item: it,
entity: auto,
touched: false,
season: it.parsed.season != null ? String(it.parsed.season) : "",
episodes: formatEpisodes(it.parsed.episodes),
slotId: "",
excluded: it.skipped === "below_min_size" ? "below_min_size" : null,
group: `${it.root}\u0000${parentRel(it.path)}`,
hnrBlocked: [...hnrPaths].some((p) => it.path === p || it.path.startsWith(`${p}/`)),
};
});
}
/** 改一行的作品:同组中未手动改过、未排除的行跟随 */
export function setRowEntity(
rows: WizardRow[],
key: string,
entity: WizardEntity | null,
propagate = true,
): WizardRow[] {
const target = rows.find((r) => r.key === key);
if (!target) return rows;
return rows.map((r) => {
if (r.key === key) return { ...r, entity, touched: true, slotId: "" };
if (propagate && r.group === target.group && !r.touched && !r.excluded) return { ...r, entity, slotId: "" };
return r;
});
}
/** 组头"应用到本组":把组内第一项已选作品复制到全组(未排除的行) */
export function applyGroupEntity(rows: WizardRow[], group: string): WizardRow[] {
const first = rows.find((r) => r.group === group && !r.excluded && r.entity);
if (!first?.entity) return rows;
const e = first.entity;
return rows.map((r) => (r.group === group && !r.excluded ? { ...r, entity: e, touched: true, slotId: "" } : r));
}
/** 批量:作品应用到选中行 */
export function applyEntityTo(rows: WizardRow[], keys: Set<string>, e: WizardEntity): WizardRow[] {
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, entity: e, touched: true, slotId: "" } : r));
}
/** 批量:季号应用到选中行 */
export function applySeasonTo(rows: WizardRow[], keys: Set<string>, season: string): WizardRow[] {
return rows.map((r) => (keys.has(r.key) && !r.excluded ? { ...r, season: season.trim() } : r));
}
/** 批量:集号偏移 ±n(选中行);偏移后小于 1 的行不改并计入 failed */
export function applyOffsetTo(rows: WizardRow[], keys: Set<string>, n: number): { rows: WizardRow[]; failed: number } {
let failed = 0;
const out = rows.map((r) => {
if (!keys.has(r.key) || r.excluded) return r;
const eps = parseEpisodes(r.episodes);
if (!eps?.length) return r;
const next = shiftEpisodes(eps, n);
if (!next) {
failed++;
return r;
}
return { ...r, episodes: formatEpisodes(next) };
});
return { rows: out, failed };
}
/**
* 最小体积变化:低于阈值的行排除(below_min_size),回到阈值以上的行取消这类排除;手动排除不动。
* 大小未知的行不动。返回需要补识别的行(之前因体积被服务端跳过、没有候选)。
*/
export function applyMinSize(rows: WizardRow[], minBytes: number): { rows: WizardRow[]; reidentify: WizardRow[] } {
const reidentify: WizardRow[] = [];
const out = rows.map((r) => {
const size = r.item.size;
if (size == null || r.excluded === "manual") return r;
if (size < minBytes) return r.excluded ? r : { ...r, excluded: "below_min_size" as const };
if (r.excluded === "below_min_size") {
if (r.item.skipped === "below_min_size") reidentify.push(r);
return { ...r, excluded: null };
}
return r;
});
return { rows: out, reidentify };
}
/** 用补识别的结果替换行的识别项(保留用户已改的字段) */
export function mergeIdentified(rows: WizardRow[], items: FileIdentifyItem[]): WizardRow[] {
const byKey = new Map(items.map((it) => [`${it.root}\u0000${it.path}`, it]));
const fresh = new Map(rowsFromIdentify(items, new Set()).map((r) => [r.key, r]));
return rows.map((r) => {
const it = byKey.get(r.key);
if (!it) return r;
const f = fresh.get(r.key);
return {
...r,
item: it,
entity: r.entity ?? f?.entity ?? null,
season: r.season || f?.season || "",
episodes: r.episodes || f?.episodes || "",
};
});
}
export type WizardStats = { videos: number; pending: number; excluded: number; bySize: number };
export function wizardStats(rows: WizardRow[]): WizardStats {
return {
videos: rows.length,
pending: rows.filter((r) => rowProblem(r)).length,
excluded: rows.filter((r) => r.excluded).length,
bySize: rows.filter((r) => r.excluded === "below_min_size").length,
};
}
/** 按源目录分组(保持首次出现顺序) */
export function groupRows(rows: WizardRow[]): { group: string; dir: string; rows: WizardRow[] }[] {
const map = new Map<string, WizardRow[]>();
for (const r of rows) map.set(r.group, [...(map.get(r.group) ?? []), r]);
return [...map.entries()].map(([group, rs]) => ({ group, dir: parentRel(rs[0]?.path ?? ""), rows: rs }));
}
// ─── 选项与请求 ──────────────────────────────────────────────────
export type WizardTarget = { mode: "auto" } | { mode: "root"; rootId: string } | { mode: "dir"; ref: FileRef };
export type WizardOptions = {
action: "copy" | "move";
target: WizardTarget;
onExisting: FilesOnExisting;
naming: "template" | "keep";
refreshMetadata: boolean;
minSizeBytes: number;
};
export const MIB = 1024 * 1024;
export const DEFAULT_MIN_SIZE = 50 * MIB;
export const defaultOptions = (o: Partial<WizardOptions> = {}): WizardOptions => ({
action: "copy",
target: { mode: "auto" },
onExisting: "conflict",
naming: "template",
refreshMetadata: true,
minSizeBytes: DEFAULT_MIN_SIZE,
...o,
});
/** `lib:<uuid>` → library_root.id */
export const libraryRootIdOf = (rootId: string) => rootId.replace(/^lib:/, "");
export function organizeItems(rows: WizardRow[], o: WizardOptions): FilesOrganizeItem[] {
return rows
.filter((r) => !r.excluded && r.entity)
.map((r) => {
const e = r.entity as WizardEntity;
const movie = isMovieKind(e.kind);
const eps = parseEpisodes(r.episodes);
const item: FilesOrganizeItem = { root: r.root, path: r.path };
if (e.id) item.mediaEntityId = e.id;
else if (e.external) item.external = e.external;
if (!movie && r.season.trim()) item.season = Number(r.season.trim());
if (!movie && eps?.length) item.episodes = eps;
if (r.slotId && e.id) item.slotId = r.slotId;
if (o.target.mode === "dir") item.targetDir = o.target.ref;
return item;
});
}
/** dryRun 与执行必须同一请求体(服务端按请求指纹比对计划),只差 dryRun/planIds/幂等键 */
export function organizeRequest(
rows: WizardRow[],
o: WizardOptions,
x: { dryRun: boolean; idempotencyKey: string; planIds?: string[] },
): FilesOrganizeRequest {
return {
idempotencyKey: x.idempotencyKey,
items: organizeItems(rows, o),
action: o.action,
targetLibraryRootId: o.target.mode === "root" ? libraryRootIdOf(o.target.rootId) : undefined,
dryRun: x.dryRun,
planIds: x.planIds,
minSizeBytes: o.minSizeBytes,
onExisting: o.onExisting,
naming: o.naming,
refreshMetadata: o.refreshMetadata,
};
}
// ─── 预览 ────────────────────────────────────────────────────────
type GroupFile = FilesOrganizeGroup["files"][number];
export const fileHasProblem = (f: GroupFile) =>
f.action === "hold" || f.existing === "conflict" || (!f.target && f.action !== "skip");
/** 组里所有文件都会被跳过(如已在库中):不算可执行 */
export const groupAllSkipped = (g: FilesOrganizeGroup) =>
g.files.length > 0 && g.files.every((f) => f.action === "skip");
export const groupExecutable = (g: FilesOrganizeGroup) => !!g.planId && !g.error && !groupAllSkipped(g);
export function groupHasProblem(g: FilesOrganizeGroup): boolean {
return (
!!g.error ||
g.warnings.length > 0 ||
(g.excluded?.length ?? 0) > 0 ||
g.files.some((f) => fileHasProblem(f) || f.action === "skip")
);
}
export type PreviewStats = { groups: number; files: number; executable: number; conflicts: number; failed: number };
export function previewStats(groups: FilesOrganizeGroup[]): PreviewStats {
return {
groups: groups.length,
files: groups.reduce((s, g) => s + g.files.length, 0),
executable: groups.filter(groupExecutable).length,
conflicts: groups.reduce((s, g) => s + g.files.filter(fileHasProblem).length, 0),
failed: groups.filter((g) => !!g.error).length,
};
}
export const PAGE_GROUPS = 20;
export function pageOf<T>(list: T[], page: number, size = PAGE_GROUPS): { items: T[]; pages: number; page: number } {
const pages = Math.max(1, Math.ceil(list.length / size));
const p = Math.min(Math.max(0, page), pages - 1);
return { items: list.slice(p * size, p * size + size), pages, page: p };
}
const MAIN_KINDS = new Set(["main", "video", "bluray_dir"]);
/** 预览文件:主文件在前,同主干的附属文件缩进挂在主文件下 */
export function nestSidecars(files: GroupFile[]): { main: GroupFile; sidecars: GroupFile[] }[] {
const mains = files.filter((f) => MAIN_KINDS.has(f.kind));
const rest = files.filter((f) => !MAIN_KINDS.has(f.kind));
const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, "");
const out = mains.map((m) => ({ main: m, sidecars: [] as GroupFile[] }));
const orphans: GroupFile[] = [];
for (const f of rest) {
// 目标名按主文件改名(主干 + .zh.ass 之类),先按目标主干匹配,再按源主干匹配
const host =
out.find((o) => o.main.target && f.target?.startsWith(`${o.main.target.replace(/\.[^./]+$/, "")}.`)) ??
out.find((o) => {
const s = stem(o.main.source);
return (f.source.split("/").pop() ?? "").startsWith(s) && parentRel(f.source) === parentRel(o.main.source);
});
if (host) host.sidecars.push(f);
else orphans.push(f);
}
return [...out, ...orphans.map((f) => ({ main: f, sidecars: [] }))];
}
export const EXCLUDE_REASON: Record<string, string> = {
below_min_size: "低于最小体积",
sample: "样片",
too_small: "体积过小",
not_imported_kind: "不入库的文件类型",
orphan_sidecar: "孤立附属文件",
no_episode: "解析不出集号",
special_extra: "特典/花絮",
extra_content: "额外内容",
no_target_dir: "没有目标目录",
naming_failed: "命名失败",
};
export const SKIP_REASON: Record<string, string> = {
already_in_library: "已在库中",
target_exists: "目标已存在",
main_not_transferred: "主文件未整理",
duplicate_target: "改名后与其他文件同名",
};
export const FILE_KIND: Record<string, string> = {
main: "主视频",
video: "视频",
subtitle: "字幕",
audio: "音轨",
nfo: "NFO",
image: "图片",
bluray_dir: "原盘目录",
other: "其他",
};
export const TEMPLATE_SOURCE: Record<string, string> = {
entity: "作品覆盖",
root: "库根",
global: "全局",
override: "保留原文件名",
};
/** 绝对路径 → 登记目录下的相对显示(找最长的 hostPath 前缀) */
export function displayPath(abs: string, roots: Pick<FileRoot, "hostPath" | "name">[]): string {
const hit = roots
.filter((r) => abs === r.hostPath || abs.startsWith(`${r.hostPath}/`))
.sort((a, b) => b.hostPath.length - a.hostPath.length)[0];
return hit ? `${hit.name}/${abs.slice(hit.hostPath.length + 1)}` : abs;
}
// ─── 来源换算 ────────────────────────────────────────────────────
export type MappingLite = {
viewKind: string;
downloaderInstanceId: string | null;
remotePrefix: string;
localPrefix: string;
enabled: boolean;
priority: number;
};
const trimSlash = (p: string) => p.replace(/\/+$/, "") || "/";
const under = (p: string, prefix: string) => p === prefix || p.startsWith(`${prefix}/`);
/** 本机绝对路径 → 登记目录 FileRef(最长前缀) */
export function localToRef(local: string, roots: Pick<FileRoot, "id" | "hostPath">[]): FileRef | null {
const p = trimSlash(local);
const hit = roots
.filter((r) => under(p, trimSlash(r.hostPath)))
.sort((a, b) => b.hostPath.length - a.hostPath.length)[0];
if (!hit) return null;
const base = trimSlash(hit.hostPath);
return { root: hit.id, path: p === base ? "" : p.slice(base.length + 1) };
}
/**
* 下载任务内容路径(下载器视角)→ 登记目录 FileRef:先按该下载器的路径映射(优先级高者先)换成本机路径,
* 没有映射时按同机处理(内容路径即本机路径)。
*/
export function downloadContentRef(
contentPath: string | null,
instanceId: string,
mappings: MappingLite[],
roots: Pick<FileRoot, "id" | "hostPath">[],
): FileRef | null {
if (!contentPath) return null;
const cp = trimSlash(contentPath);
const own = mappings
.filter((m) => m.enabled && m.viewKind === "downloader" && m.downloaderInstanceId === instanceId)
.sort((a, b) => b.priority - a.priority || b.remotePrefix.length - a.remotePrefix.length);
for (const m of own) {
const rp = trimSlash(m.remotePrefix);
if (!under(cp, rp)) continue;
const local = `${trimSlash(m.localPrefix)}${cp.slice(rp.length)}`;
const ref = localToRef(local, roots);
if (ref) return ref;
}
return localToRef(cp, roots);
}
@@ -0,0 +1,520 @@
import { useQuery } from "@tanstack/react-query";
import type { CursorPage, PathMapping } from "@umt/contracts";
import { ArrowLeft, ArrowRight, Check, FileStack, LoaderCircle, RotateCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, useNavigate } from "react-router";
import { toast } from "sonner";
import {
type FileRef,
type FileRoot,
type FilesOrganizeGroup,
filesErrorReason,
filesErrorText,
filesMockEnabled,
useFileRecords,
useFileRoots,
useIdentify,
useOrganizeFiles,
useRecordSources,
} from "~/api/files.ts";
import { useRecord } from "~/api/records.ts";
import { useSettings } from "~/api/settings.ts";
import { baseName, newIdempotencyKey } from "~/components/files/model.ts";
import { useIsMobile } from "~/components/files/use-mobile.ts";
import { Button } from "~/components/ui/button.tsx";
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "~/components/ui/sheet.tsx";
import { apiData } from "~/lib/api.ts";
import { cn } from "~/lib/utils.ts";
import {
applyEntityTo,
applyGroupEntity,
applyMinSize,
applyOffsetTo,
applySeasonTo,
DEFAULT_MIN_SIZE,
defaultOptions,
downloadContentRef,
entityLabel,
groupExecutable,
MIB,
mergeIdentified,
organizeRequest,
previewStats,
rowsFromIdentify,
setRowEntity,
type WizardEntity,
type WizardOptions,
type WizardRow,
wizardStats,
} from "./model.ts";
import { mbToBytes, StepFiles } from "./step-files.tsx";
import { StepOptions } from "./step-options.tsx";
import { PreviewGroupCard, PreviewList, PreviewStatsBar } from "./step-preview.tsx";
/** 向导来源(§6):文件管理选中项 / 下载任务内容路径 / 整理记录源路径 */
export type OrganizeSource =
| { kind: "files"; items: FileRef[]; hnrPaths?: string[]; label?: string; preset?: WizardEntity | null }
| {
kind: "download";
taskId: string;
instanceId: string;
contentPath: string | null;
name: string;
/** 做种中且 H&R 未满足:只能复制 */
hnrBlocked?: boolean;
}
| { kind: "record"; recordId: string };
const STEPS = ["文件与作品", "选项", "预览并执行"] as const;
type Resolved = {
items: FileRef[];
hnrPaths: string[];
unresolved: string[];
preset: WizardEntity | null;
label: string;
pending: boolean;
error: unknown;
};
/** 把三种来源换算成登记目录内的 FileRef */
function useResolvedSource(source: OrganizeSource, roots: FileRoot[] | undefined): Resolved {
const isDl = source.kind === "download";
const isRec = source.kind === "record";
const maps = useQuery({
queryKey: ["files", "wizard-path-mappings"],
enabled: isDl && !filesMockEnabled(),
retry: false,
queryFn: ({ signal }) => apiData<CursorPage<PathMapping>>("/path-mappings", { params: { limit: 200 }, signal }),
});
const recSrc = useRecordSources(isRec ? source.recordId : null);
const rec = useRecord(isRec ? source.recordId : undefined);
return useMemo((): Resolved => {
const base = { unresolved: [], preset: null, pending: false, error: null };
if (source.kind === "files")
return {
...base,
items: source.items,
hnrPaths: source.hnrPaths ?? [],
preset: source.preset ?? null,
label:
source.label ??
(source.items.length === 1 ? baseName(source.items[0]?.path ?? "") : `${source.items.length} 项`),
};
if (source.kind === "download") {
if (!roots || (maps.isLoading && !filesMockEnabled()))
return { ...base, items: [], hnrPaths: [], label: source.name, pending: true };
const ref = downloadContentRef(source.contentPath, source.instanceId, maps.data?.items ?? [], roots);
return {
...base,
items: ref ? [ref] : [],
hnrPaths: ref && source.hnrBlocked ? [ref.path] : [],
unresolved: ref ? [] : [source.contentPath ?? "(下载任务没有内容路径)"],
label: `下载任务 · ${source.name}`,
};
}
if (recSrc.isPending) return { ...base, items: [], hnrPaths: [], label: "整理记录", pending: true };
if (recSrc.error) return { ...base, items: [], hnrPaths: [], label: "整理记录", error: recSrc.error };
const m = rec.data?.media;
return {
...base,
items: recSrc.data?.items ?? [],
hnrPaths: [],
unresolved: recSrc.data?.unresolved ?? [],
preset:
m && recSrc.data?.mediaEntityId
? { id: recSrc.data.mediaEntityId, external: null, title: m.title, year: m.year, kind: m.kind }
: null,
label: `重新整理 · ${m ? entityLabel(m) : "整理记录"}`,
};
}, [source, roots, maps.isLoading, maps.data, recSrc.isPending, recSrc.error, recSrc.data, rec.data]);
}
/**
* 整理向导(唯一入口,§6):① 文件与作品 → ② 选项 → ③ 预览并执行。
* 文件管理、下载详情、整理记录"重新整理"共用。执行后原地显示每组结果与整理记录进度。
*/
export function OrganizeWizard({ source, onClose }: { source: OrganizeSource; onClose: () => void }) {
const navigate = useNavigate();
const mobile = useIsMobile();
const rootsQ = useFileRoots();
const roots = rootsQ.data;
const settings = useSettings();
const src = useResolvedSource(source, roots);
const [step, setStep] = useState(0);
const [rows, setRows] = useState<WizardRow[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [options, setOptions] = useState<WizardOptions>(defaultOptions());
const [minSizeMb, setMinSizeMb] = useState(String(DEFAULT_MIN_SIZE / MIB));
const [truncated, setTruncated] = useState(false);
const [preview, setPreview] = useState<{ groups: FilesOrganizeGroup[]; rows: WizardRow[]; key: string } | null>(null);
const [results, setResults] = useState<FilesOrganizeGroup[]>([]);
const [recordIds, setRecordIds] = useState<string[]>([]);
const [notice, setNotice] = useState<string | null>(null);
const [onlyProblems, setOnlyProblems] = useState(false);
const [page, setPage] = useState(0);
const identify = useIdentify();
const reidentify = useIdentify();
const dry = useOrganizeFiles();
const exec = useOrganizeFiles();
const records = useFileRecords({ ids: recordIds }, recordIds.length > 0);
// 设置里的"整理默认"(方式 / 最小体积);读不到用默认值
const ready = settings.isFetched || settings.isError || filesMockEnabled();
const defaults = useMemo(() => {
const o = settings.data?.organize;
return defaultOptions({
action: o?.defaultMode === "move" ? "move" : "copy",
minSizeBytes: o?.minSizeBytes ?? DEFAULT_MIN_SIZE,
});
}, [settings.data]);
// 来源就绪 → 识别(一次)
const [started, setStarted] = useState(false);
const identifyMutate = identify.mutate;
useEffect(() => {
if (started || !ready || src.pending || !src.items.length) return;
setStarted(true);
setOptions(defaults);
setMinSizeMb(String(Math.round(defaults.minSizeBytes / MIB)));
identifyMutate(
{ items: src.items, minSizeBytes: defaults.minSizeBytes },
{
onSuccess: (r) => {
const hnr = new Set(src.hnrPaths);
let next = rowsFromIdentify(r.items, hnr);
if (src.preset) {
const p = src.preset;
next = next.map((x) => (x.excluded || x.entity?.id === p.id ? x : { ...x, entity: p }));
}
setRows(next);
setTruncated(r.truncated);
},
},
);
}, [started, ready, src, defaults, identifyMutate]);
const active = rows.filter((r) => !r.excluded);
const stats = wizardStats(rows);
const hnrCount = active.filter((r) => r.hnrBlocked).length;
const kinds = new Set(active.map((r) => r.entity?.kind).filter((k): k is string => !!k));
const canNext = active.length > 0 && stats.pending === 0;
useEffect(() => {
if (hnrCount && options.action === "move") setOptions((o) => ({ ...o, action: "copy" }));
}, [hnrCount, options.action]);
const rootName = useCallback((id: string) => roots?.find((r) => r.id === id)?.name ?? id, [roots]);
// ── ① 批量操作 ──
const onMinSizeMb = (v: string) => {
setMinSizeMb(v);
const bytes = mbToBytes(v);
if (bytes == null) return;
setOptions((o) => ({ ...o, minSizeBytes: bytes }));
const r = applyMinSize(rows, bytes);
setRows(r.rows);
if (r.reidentify.length)
reidentify.mutate(
{ items: r.reidentify.map((x) => ({ root: x.root, path: x.path })), minSizeBytes: bytes },
{
onSuccess: (res) => setRows((rs) => mergeIdentified(rs, res.items)),
onError: (e) => toast.error(filesErrorText(e, "补充识别失败")),
},
);
};
// ── ③ 预览 / 执行 ──
const titleOf = useCallback(
(g: FilesOrganizeGroup) => {
if (g.entity) return g.entity.title;
const r = rows.find((x) => x.entity?.id === g.mediaEntityId);
return r?.entity ? entityLabel(r.entity) : g.mediaEntityId.slice(0, 8);
},
[rows],
);
const runPreview = (useRows: WizardRow[], note: string | null = null) => {
const key = newIdempotencyKey();
setNotice(note);
setPage(0);
setStep(2);
setPreview(null);
dry.mutate(organizeRequest(useRows, options, { dryRun: true, idempotencyKey: `${key}-dry` }), {
onSuccess: (r) => setPreview({ groups: r.groups, rows: useRows, key }),
onError: (e) => toast.error(filesErrorText(e, "生成预览失败")),
});
};
const executable = preview?.groups.filter(groupExecutable) ?? [];
const runExec = () => {
if (!preview || !executable.length) return;
const body = organizeRequest(preview.rows, options, {
dryRun: false,
idempotencyKey: preview.key,
planIds: executable.map((g) => g.planId as string),
});
exec.mutate(body, {
onSuccess: (r) => {
const stale = r.groups.filter((g) => g.error?.code === "plan_stale");
const done = r.groups.filter((g) => g.error?.code !== "plan_stale");
setResults((prev) => [...prev, ...done]);
setRecordIds((prev) => [...new Set([...prev, ...r.recordIds])]);
if (stale.length) {
const ids = new Set(stale.map((g) => g.mediaEntityId));
const ext = new Set(stale.map((g) => g.entity?.externalId).filter(Boolean));
const again = preview.rows.filter(
(x) =>
(x.entity?.id && ids.has(x.entity.id)) || (x.entity?.external && ext.has(x.entity.external.externalId)),
);
runPreview(again.length ? again : preview.rows, `${stale.length} 组源文件在预览后有变化,已重新预览`);
} else setPreview(null);
},
onError: (e) => {
if (filesErrorReason(e) === "plan_stale") {
runPreview(preview.rows, "源文件在预览后有变化,已重新预览");
toast.warning("源文件在预览后有变化,已重新预览");
} else toast.error(filesErrorText(e, "执行失败"));
},
});
};
const recordsHref =
recordIds.length === 1 ? `/console/organize/${recordIds[0]}` : `/console/organize?ids=${recordIds.join(",")}`;
const recordById = new Map((records.data?.items ?? []).map((r) => [r.id, r]));
const executed = results.length > 0 && !preview && !dry.isPending;
// ── 渲染 ──
let body: React.ReactNode;
if (step === 0) {
if (src.pending || (!started && !src.error && src.items.length > 0) || identify.isPending) {
body = (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" />
{src.pending ? "定位源文件…" : "识别中:匹配本地作品与 TMDB…"}
</p>
);
} else if (src.error || (!src.items.length && src.unresolved.length) || !src.items.length) {
body = (
<div className="space-y-2 text-caption">
<p className="text-destructive">
{src.error ? filesErrorText(src.error, "读取整理记录失败") : "源文件不在任何登记目录里,或已经不存在。"}
</p>
{src.unresolved.length ? (
<ul className="space-y-0.5 font-mono text-meta text-muted-foreground">
{src.unresolved.map((p) => (
<li key={p} className="break-all">
{p}
</li>
))}
</ul>
) : null}
<p className="text-muted-foreground">
在设置里检查下载器路径映射,或到
<Link to="/console/files" className="mx-1 underline underline-offset-2" onClick={onClose}>
文件管理
</Link>
里选中文件后整理。
</p>
</div>
);
} else if (identify.error) {
body = (
<div className="space-y-2">
<p className="text-caption text-destructive">{filesErrorText(identify.error, "识别失败")}</p>
<Button size="sm" variant="outline" onClick={() => setStarted(false)}>
<RotateCw />
重试
</Button>
</div>
);
} else if (!rows.length) {
body = <p className="text-caption text-muted-foreground">选中的项里没有视频文件。</p>;
} else {
body = (
<div className="space-y-3">
{src.unresolved.length ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
{src.unresolved.length} 个源路径不在登记目录里或已不存在,已跳过。
</p>
) : null}
<StepFiles
rows={rows}
stats={stats}
selected={selected}
onSelected={setSelected}
onEntity={(k, e) => setRows((rs) => setRowEntity(rs, k, e))}
onPatch={(k, p) => setRows((rs) => rs.map((r) => (r.key === k ? { ...r, ...p } : r)))}
onApplyGroup={(g) => setRows((rs) => applyGroupEntity(rs, g))}
onBulkEntity={(e) => setRows((rs) => applyEntityTo(rs, selected, e))}
onBulkSeason={(s) => setRows((rs) => applySeasonTo(rs, selected, s))}
onBulkOffset={(n) => {
const r = applyOffsetTo(rows, selected, n);
setRows(r.rows);
if (r.failed) toast.warning(`${r.failed} 项偏移后集号小于 1,没有改`);
}}
minSizeMb={minSizeMb}
onMinSizeMb={onMinSizeMb}
truncated={truncated}
mobile={mobile}
rootName={rootName}
/>
</div>
);
}
} else if (step === 1) {
body = (
<StepOptions options={options} onChange={setOptions} roots={roots ?? []} hnrCount={hnrCount} kinds={kinds} />
);
} else {
const groups = preview?.groups ?? [];
body = (
<div className="space-y-4">
{notice ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-caption text-warning">
{notice}
</p>
) : null}
{results.length ? (
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">执行结果</h3>
{results.map((g, i) => (
<PreviewGroupCard
key={`r-${g.recordId ?? i}`}
g={g}
title={titleOf(g)}
roots={roots ?? []}
onlyProblems={false}
result
record={g.recordId ? recordById.get(g.recordId) : undefined}
/>
))}
{executed ? (
<p className="text-caption text-muted-foreground">整理在后台执行,状态每 3 秒刷新,文件列表会自动更新。</p>
) : null}
</section>
) : null}
{dry.isPending ? (
<p className="flex items-center gap-2 text-caption text-muted-foreground">
<LoaderCircle className="h-4 w-4 animate-spin" /> 生成整理计划…
</p>
) : preview ? (
<div className="space-y-3">
<PreviewStatsBar
stats={previewStats(groups)}
onlyProblems={onlyProblems}
onOnlyProblems={setOnlyProblems}
/>
<PreviewList
groups={groups}
titleOf={titleOf}
roots={roots ?? []}
onlyProblems={onlyProblems}
page={page}
onPage={setPage}
/>
</div>
) : !results.length ? (
<p className="text-caption text-destructive">{filesErrorText(dry.error, "生成预览失败")}</p>
) : null}
</div>
);
}
return (
<Sheet open onOpenChange={(o) => !o && onClose()}>
<SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-6xl">
<SheetHeader className="border-b border-[var(--shell-border)] px-5 py-4 pr-14">
<SheetTitle className="text-lead">整理</SheetTitle>
<SheetDescription className="truncate">{src.label}</SheetDescription>
<ol className="mt-3 flex flex-wrap items-center gap-1.5" aria-label="步骤">
{STEPS.map((s, i) => (
<li key={s} className="flex items-center gap-1.5">
{i > 0 ? <span aria-hidden className="h-px w-4 bg-border" /> : null}
<span
aria-current={step === i ? "step" : undefined}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-meta",
step === i
? "pill-selected text-foreground"
: i < step
? "border-border text-foreground"
: "border-border text-muted-foreground",
)}
>
{i < step ? <Check className="h-3 w-3" /> : <span className="tabular-nums">{i + 1}</span>}
{s}
</span>
</li>
))}
</ol>
</SheetHeader>
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4 sm:px-5">{body}</div>
<div className="flex flex-wrap items-center justify-between gap-2 border-t border-[var(--shell-border)] px-5 py-3">
<div>
{step === 1 ? (
<Button variant="ghost" onClick={() => setStep(0)}>
<ArrowLeft />
上一步
</Button>
) : null}
{step === 2 && !executed ? (
<Button variant="ghost" onClick={() => setStep(0)} disabled={exec.isPending}>
<ArrowLeft />
返回修改
</Button>
) : null}
</div>
<div className="flex items-center gap-2">
{step === 0 ? (
<>
{rows.length && !canNext ? (
<span className="text-meta text-warning">
{stats.pending ? `${stats.pending} 项待定:选作品或排除` : "没有要整理的项"}
</span>
) : null}
<Button onClick={() => setStep(1)} disabled={!canNext}>
下一步
<ArrowRight />
</Button>
</>
) : null}
{step === 1 ? (
<Button
onClick={() => runPreview(rows)}
disabled={options.target.mode === "dir" && !options.target.ref.root}
>
生成预览
<ArrowRight />
</Button>
) : null}
{step === 2 && preview ? (
<Button onClick={runExec} disabled={!executable.length || exec.isPending}>
{exec.isPending ? <LoaderCircle className="animate-spin" /> : null}
执行 {executable.length} 组
</Button>
) : null}
{step === 2 && executed ? (
<>
<Button
variant="outline"
disabled={!recordIds.length}
onClick={() => {
onClose();
navigate(recordsHref);
}}
>
<FileStack />
查看整理记录
</Button>
<Button onClick={onClose}>完成</Button>
</>
) : null}
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,51 @@
import { ChevronRight } from "lucide-react";
import { cn } from "~/lib/utils.ts";
export type PathSegment = { text: string; role: string; bound?: boolean };
const ROLE: Record<string, { label: string; cls: string }> = {
category: { label: "分类", cls: "text-muted-foreground" },
root: { label: "作品目录", cls: "text-foreground font-medium" },
season: { label: "季目录", cls: "text-foreground" },
dir: { label: "子目录", cls: "text-muted-foreground" },
file: { label: "文件名", cls: "text-foreground" },
};
/**
* 目标路径分段着色(§6.3):作品目录 / 季目录 / 子目录 / 文件名,已沿用路径绑定的段加"沿用绑定"标签。
* 向导预览与重命名共用。
*/
export function PathSegments({
segments,
prefix,
className,
}: {
segments: PathSegment[];
/** 段前的固定部分(库根名或本机路径),淡显 */
prefix?: string | null;
className?: string;
}) {
return (
<p className={cn("flex min-w-0 flex-wrap items-center gap-x-0.5 gap-y-1 font-mono text-meta", className)}>
{prefix ? <span className="break-all text-muted-foreground/80">{prefix}</span> : null}
{segments.map((s, i) => {
const r = ROLE[s.role] ?? ROLE.dir;
return (
<span key={`${i}-${s.text}`} className="inline-flex min-w-0 items-center gap-0.5">
{prefix || i > 0 ? (
<ChevronRight aria-hidden className="h-3 w-3 shrink-0 text-muted-foreground/60" />
) : null}
<span className={cn("break-all", r?.cls)} title={r?.label}>
{s.text}
</span>
{s.bound ? (
<span className="ml-1 rounded-tight border border-border px-1 font-sans text-micro text-muted-foreground">
沿用绑定
</span>
) : null}
</span>
);
})}
</p>
);
}
@@ -0,0 +1,519 @@
import { ChevronDown, Undo2, X } from "lucide-react";
import { useState } from "react";
import { Link } from "react-router";
import { filesMockEnabled } from "~/api/files.ts";
import { useSlots } from "~/api/slots.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { Checkbox } from "~/components/files/file-list.tsx";
import { baseName, contentUnitLabel } from "~/components/files/model.ts";
import { SLOT_STATUS } from "~/components/library/labels.ts";
import { Button } from "~/components/ui/button.tsx";
import { Input } from "~/components/ui/input.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import { EntityPicker } from "./entity-picker.tsx";
import {
groupRows,
isMovieKind,
MIB,
rowProblem,
type WizardEntity,
type WizardRow,
type WizardStats,
} from "./model.ts";
type RowPatch = Partial<Pick<WizardRow, "season" | "episodes" | "slotId" | "excluded">>;
export type StepFilesProps = {
rows: WizardRow[];
stats: WizardStats;
selected: Set<string>;
onSelected: (s: Set<string>) => void;
onEntity: (key: string, e: WizardEntity | null) => void;
onPatch: (key: string, p: RowPatch) => void;
onApplyGroup: (group: string) => void;
onBulkEntity: (e: WizardEntity) => void;
onBulkSeason: (s: string) => void;
onBulkOffset: (n: number) => void;
minSizeMb: string;
onMinSizeMb: (v: string) => void;
truncated: boolean;
mobile: boolean;
rootName: (rootId: string) => string;
};
/** 向导 ① 批量条(§6.1 WizardBulkBar):作品 / 季 / 集号偏移 / 最小体积 + 统计 */
export function WizardBulkBar({
selectedCount,
onBulkEntity,
onBulkSeason,
onBulkOffset,
minSizeMb,
onMinSizeMb,
stats,
}: {
selectedCount: number;
onBulkEntity: (e: WizardEntity) => void;
onBulkSeason: (s: string) => void;
onBulkOffset: (n: number) => void;
minSizeMb: string;
onMinSizeMb: (v: string) => void;
stats: WizardStats;
}) {
const [season, setSeason] = useState("");
const [offset, setOffset] = useState("");
const off = Number(offset);
const none = selectedCount === 0;
const hint = none ? "先勾选要改的行" : undefined;
return (
<div className="space-y-2 rounded-xl border border-border bg-foreground/[0.02] p-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 text-caption">
<span className="text-muted-foreground">已选 {selectedCount} 项</span>
<div className="flex min-w-0 items-center gap-1.5" title={hint}>
<span className="shrink-0 text-muted-foreground">作品</span>
<div className={cn("w-52 min-w-0", none && "pointer-events-none opacity-50")}>
<EntityPicker value={null} onChange={(e) => e && onBulkEntity(e)} placeholder="应用到选中…" />
</div>
</div>
<div className="flex items-center gap-1.5" title={hint}>
<span className="text-muted-foreground">季</span>
<Input
value={season}
onChange={(e) => setSeason(e.target.value)}
inputMode="numeric"
aria-label="批量季号"
className="h-8 w-14 text-center"
/>
<Button
size="sm"
variant="outline"
disabled={none || !/^\d{1,4}$/.test(season.trim())}
onClick={() => onBulkSeason(season)}
>
应用
</Button>
</div>
<div className="flex items-center gap-1.5" title={hint}>
<span className="text-muted-foreground">集号偏移</span>
<Input
value={offset}
onChange={(e) => setOffset(e.target.value)}
placeholder="±N"
aria-label="集号偏移量(可为负)"
className="h-8 w-16 text-center"
/>
<Button
size="sm"
variant="outline"
disabled={none || !/^[+-]?\d{1,4}$/.test(offset.trim()) || !off}
onClick={() => onBulkOffset(off)}
>
应用
</Button>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">最小体积</span>
<Input
value={minSizeMb}
onChange={(e) => onMinSizeMb(e.target.value)}
inputMode="numeric"
aria-label="最小体积(MB)"
className="h-8 w-16 text-center"
/>
<span className="text-muted-foreground">MB</span>
</div>
</div>
<div className="flex flex-wrap gap-1.5">
<StatusBadge>{stats.videos} 个视频</StatusBadge>
{stats.pending ? (
<StatusBadge tone="warning">{stats.pending} 项待定</StatusBadge>
) : (
<StatusBadge tone="success">没有待定项</StatusBadge>
)}
{stats.excluded ? (
<StatusBadge>
{stats.excluded} 项已排除{stats.bySize ? `(${stats.bySize} 项低于最小体积)` : ""}
</StatusBadge>
) : null}
</div>
</div>
);
}
function RowMarks({ row }: { row: WizardRow }) {
const it = row.item;
return (
<span className="flex flex-wrap gap-1">
{it.existing.assetId ? (
<StatusBadge tone="neutral" className="whitespace-nowrap">
已登记
</StatusBadge>
) : null}
{it.records?.count ? (
it.records.lastRecordId ? (
<Link to={`/console/organize/${it.records.lastRecordId}`} target="_blank" className="hover:underline">
<StatusBadge tone="neutral">有 {it.records.count} 条整理记录</StatusBadge>
</Link>
) : (
<StatusBadge tone="neutral">有 {it.records.count} 条整理记录</StatusBadge>
)
) : null}
{row.hnrBlocked ? <StatusBadge tone="warning">做种中·H&R 未满足</StatusBadge> : null}
{row.excluded === "below_min_size" ? <StatusBadge>低于最小体积</StatusBadge> : null}
{row.entity && !row.entity.id ? <StatusBadge tone="warning">将新建作品</StatusBadge> : null}
</span>
);
}
function parsedLine(row: WizardRow) {
const p = row.item.parsed;
return [
p.title ?? "解析不出标题",
p.year ? String(p.year) : null,
p.season != null ? contentUnitLabel(p.season, p.episodes) : null,
p.resolution,
p.source,
p.releaseGroup,
row.item.size != null ? formatBytes(row.item.size, 1) : null,
]
.filter(Boolean)
.join(" · ");
}
function SlotSelect({
entity,
value,
onChange,
}: {
entity: WizardEntity | null;
value: string;
onChange: (v: string) => void;
}) {
const mock = filesMockEnabled();
const id = entity?.id ?? null;
const slots = useSlots({ mediaEntityId: id ?? undefined }, !!id && !mock);
const items = slots.data?.items ?? [];
return (
<select
aria-label="版本目标"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={!id}
title={entity && !id ? "新建的作品还没有版本目标" : undefined}
className="h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2 text-caption disabled:opacity-50"
>
<option value="">自动</option>
{items.map((s) => (
<option key={s.id} value={s.id}>
{s.name} · {SLOT_STATUS[s.status]?.label ?? s.status}
</option>
))}
</select>
);
}
function ExcludeButton({ row, onPatch }: { row: WizardRow; onPatch: (p: RowPatch) => void }) {
return (
<Button
variant="ghost"
size="xs"
onClick={() => onPatch({ excluded: row.excluded ? null : "manual" })}
aria-label={row.excluded ? `恢复 ${baseName(row.path)}` : `排除 ${baseName(row.path)}`}
>
{row.excluded ? <Undo2 /> : <X />}
{row.excluded ? "恢复" : "排除"}
</Button>
);
}
/** 向导 ①:按源目录分组的表格(桌面)/ 紧凑卡片(手机) */
export function StepFiles(p: StepFilesProps) {
const groups = groupRows(p.rows);
const allKeys = p.rows.filter((r) => !r.excluded).map((r) => r.key);
const toggle = (k: string) => {
const n = new Set(p.selected);
if (n.has(k)) n.delete(k);
else n.add(k);
p.onSelected(n);
};
const [bulkOpen, setBulkOpen] = useState(false);
const bulk = (
<WizardBulkBar
selectedCount={p.selected.size}
onBulkEntity={p.onBulkEntity}
onBulkSeason={p.onBulkSeason}
onBulkOffset={p.onBulkOffset}
minSizeMb={p.minSizeMb}
onMinSizeMb={p.onMinSizeMb}
stats={p.stats}
/>
);
return (
<div className="space-y-3">
{p.mobile ? (
<div>
<button
type="button"
aria-expanded={bulkOpen}
onClick={() => setBulkOpen((v) => !v)}
className="flex w-full items-center justify-between rounded-xl border border-border px-3 py-2 text-caption"
>
<span>
批量设置 · {p.stats.videos} 个视频{p.stats.pending ? ` · ${p.stats.pending} 项待定` : ""}
</span>
<ChevronDown className={cn("h-4 w-4 transition-transform", bulkOpen && "rotate-180")} />
</button>
{bulkOpen ? <div className="mt-2">{bulk}</div> : null}
</div>
) : (
bulk
)}
{p.truncated ? (
<p className="rounded-lg border border-warning/35 bg-warning/10 px-3 py-1.5 text-meta text-warning">
目录里视频超过 200 个,只识别了前 200 个。进入子目录分批整理。
</p>
) : null}
{p.mobile ? (
<div className="space-y-4">
{groups.map((g) => (
<section key={g.group} className="space-y-2">
<GroupHead
dir={g.dir}
rootName={p.rootName(g.rows[0]?.root ?? "")}
count={g.rows.length}
onApply={() => p.onApplyGroup(g.group)}
canApply={g.rows.length > 1 && g.rows.some((r) => r.entity && !r.excluded)}
/>
<ul className="space-y-2">
{g.rows.map((r) => {
const problem = rowProblem(r);
const movie = isMovieKind(r.entity?.kind);
return (
<li
key={r.key}
className={cn(
"rounded-xl border px-3 py-2",
r.excluded
? "border-border opacity-60"
: problem
? "border-warning/50 bg-warning/[0.06]"
: "border-border",
)}
>
<div className="flex items-start gap-2">
<Checkbox
checked={p.selected.has(r.key)}
label={`选择 ${baseName(r.path)}`}
onToggle={() => toggle(r.key)}
/>
<p className="min-w-0 flex-1 truncate font-mono text-caption" title={r.path}>
{baseName(r.path)}
</p>
<ExcludeButton row={r} onPatch={(x) => p.onPatch(r.key, x)} />
</div>
{!r.excluded ? (
<div className="mt-1.5 grid grid-cols-[minmax(0,1fr)_3.25rem_4.5rem] gap-1.5">
<EntityPicker
candidates={r.item.candidates}
value={r.entity}
onChange={(e) => p.onEntity(r.key, e)}
invalid={!r.entity}
/>
<Input
aria-label="季"
placeholder="季"
value={r.season}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { season: e.target.value })}
className="h-8 px-1 text-center"
/>
<Input
aria-label="集"
placeholder="集"
value={r.episodes}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { episodes: e.target.value })}
className="h-8 px-1 text-center"
/>
</div>
) : null}
<div className="mt-1">
<RowMarks row={r} />
</div>
{problem ? <p className="mt-1 text-meta text-warning">{problem}</p> : null}
</li>
);
})}
</ul>
</section>
))}
</div>
) : (
<div className="overflow-x-auto rounded-xl border border-border">
<table className="w-full min-w-[56rem] border-collapse text-caption">
<thead>
<tr className="border-b border-border text-left text-meta text-muted-foreground">
<th className="w-9 px-2 py-2">
<Checkbox
checked={allKeys.length > 0 && allKeys.every((k) => p.selected.has(k))}
partial={p.selected.size > 0 && !allKeys.every((k) => p.selected.has(k))}
label="全选"
onToggle={() =>
p.onSelected(allKeys.every((k) => p.selected.has(k)) ? new Set() : new Set(allKeys))
}
/>
</th>
<th className="px-2 py-2 font-medium">源文件</th>
<th className="w-[15rem] px-2 py-2 font-medium">作品</th>
<th className="w-16 px-2 py-2 font-medium">季</th>
<th className="w-24 px-2 py-2 font-medium">集</th>
<th className="w-40 px-2 py-2 font-medium">版本目标</th>
<th className="w-20 px-2 py-2">
<span className="sr-only">操作</span>
</th>
</tr>
</thead>
{groups.map((g) => (
<tbody key={g.group}>
<tr className="border-b border-border/60 bg-foreground/[0.03]">
<td colSpan={7} className="px-2 py-1.5">
<GroupHead
dir={g.dir}
rootName={p.rootName(g.rows[0]?.root ?? "")}
count={g.rows.length}
onApply={() => p.onApplyGroup(g.group)}
canApply={g.rows.length > 1 && g.rows.some((r) => r.entity && !r.excluded)}
/>
</td>
</tr>
{g.rows.map((r) => {
const problem = rowProblem(r);
const movie = isMovieKind(r.entity?.kind);
return (
<tr
key={r.key}
className={cn(
"border-b border-border/50 align-top",
r.excluded ? "opacity-55" : problem ? "bg-warning/[0.07]" : "",
)}
>
<td className="px-2 py-2">
<Checkbox
checked={p.selected.has(r.key)}
label={`选择 ${baseName(r.path)}`}
onToggle={() => toggle(r.key)}
/>
</td>
<td className="max-w-0 px-2 py-1.5">
<p className="truncate font-mono text-caption text-foreground" title={r.path}>
{baseName(r.path)}
</p>
<p className="truncate text-meta text-muted-foreground" title={parsedLine(r)}>
{parsedLine(r)}
</p>
<div className="mt-0.5 empty:hidden">
<RowMarks row={r} />
</div>
{problem ? <p className="text-meta text-warning">{problem}</p> : null}
{(r.item.skipped ? [] : r.item.warnings).map((w) => (
<p key={w} className="text-meta text-warning">
{w}
</p>
))}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<EntityPicker
candidates={r.item.candidates}
value={r.entity}
onChange={(e) => p.onEntity(r.key, e)}
invalid={!r.entity}
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<Input
aria-label={`${baseName(r.path)} 季`}
value={movie ? "" : r.season}
placeholder={movie ? "—" : ""}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { season: e.target.value })}
className="h-8 px-1 text-center"
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<Input
aria-label={`${baseName(r.path)} 集`}
value={movie ? "" : r.episodes}
placeholder={movie ? "—" : "如 3-5"}
disabled={movie}
onChange={(e) => p.onPatch(r.key, { episodes: e.target.value })}
className="h-8 px-1 text-center"
/>
)}
</td>
<td className="px-2 py-1.5">
{r.excluded ? null : (
<SlotSelect
entity={r.entity}
value={r.slotId}
onChange={(v) => p.onPatch(r.key, { slotId: v })}
/>
)}
</td>
<td className="px-1 py-1.5 text-right">
<ExcludeButton row={r} onPatch={(x) => p.onPatch(r.key, x)} />
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
)}
</div>
);
}
function GroupHead({
dir,
rootName,
count,
onApply,
canApply,
}: {
dir: string;
rootName: string;
count: number;
onApply: () => void;
canApply: boolean;
}) {
return (
<div className="flex min-w-0 items-center gap-2 text-meta">
<span className="min-w-0 truncate font-mono text-foreground" title={`${rootName}/${dir}`}>
{dir ? dir.split("/").pop() : rootName}
</span>
<span className="shrink-0 text-muted-foreground">{count} 项</span>
{canApply ? (
<button
type="button"
onClick={onApply}
className="ml-auto shrink-0 text-muted-foreground underline underline-offset-2 hover:text-foreground"
title="把本组第一项的作品应用到本组全部"
>
应用到本组
</button>
) : null}
</div>
);
}
export const mbToBytes = (v: string) => {
const n = Number(v.trim());
return Number.isFinite(n) && n >= 0 ? Math.round(n * MIB) : null;
};
@@ -0,0 +1,222 @@
import { Link } from "react-router";
import type { FileRoot } from "~/api/files.ts";
import { DirPicker } from "~/components/files/dir-picker.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import type { WizardOptions } from "./model.ts";
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section className="space-y-2">
<h3 className="text-label font-semibold tracking-[0.18em] text-muted-foreground uppercase">{title}</h3>
{children}
</section>
);
}
function Choice({
checked,
disabled,
onSelect,
title,
hint,
}: {
checked: boolean;
disabled?: string | null;
onSelect: () => void;
title: string;
hint?: React.ReactNode;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: 带说明文字的整卡单选,原生 radio 无法套卡片样式;键盘可达(button)
<button
type="button"
role="radio"
aria-checked={checked}
aria-disabled={!!disabled || undefined}
title={disabled ?? undefined}
onClick={() => !disabled && onSelect()}
className={cn(
"flex w-full items-start gap-2.5 rounded-xl border px-3 py-2 text-left transition-colors duration-micro",
checked ? "pill-selected" : "border-border hover:bg-foreground/[0.04]",
disabled && "cursor-not-allowed opacity-50 hover:bg-transparent",
)}
>
<span
aria-hidden
className={cn(
"mt-0.5 grid h-4 w-4 shrink-0 place-items-center rounded-full border",
checked ? "border-foreground" : "border-border",
)}
>
{checked ? <span className="h-2 w-2 rounded-full bg-foreground" /> : null}
</span>
<span className="min-w-0">
<span className="block text-body">{title}</span>
{disabled ? (
<span className="block text-meta text-muted-foreground">{disabled}</span>
) : hint ? (
<span className="block text-meta text-muted-foreground">{hint}</span>
) : null}
</span>
</button>
);
}
/** 向导 ②:MP 手动整理字段的映射(§6.2) */
export function StepOptions({
options,
onChange,
roots,
hnrCount,
kinds,
}: {
options: WizardOptions;
onChange: (o: WizardOptions) => void;
roots: FileRoot[];
/** 做种中且 H&R 未满足的行数:大于 0 时不能移动 */
hnrCount: number;
/** 本次涉及的作品类型(过滤库根) */
kinds: Set<string>;
}) {
const set = (p: Partial<WizardOptions>) => onChange({ ...options, ...p });
const libRoots = roots.filter((r) => r.kind === "library" && r.writable && r.health !== "offline");
/** 库根类型与本次作品类型不符时的原因 */
const rootMismatch = (r: FileRoot): string | null => {
if (r.mediaKind === "mixed" || r.mediaKind === "other" || !r.mediaKind) return null;
const hasMovie = [...kinds].some((k) => k === "movie");
const hasShow = [...kinds].some((k) => k !== "movie");
if (r.mediaKind === "movie" && hasShow) return "电影库,本次含剧集";
if (r.mediaKind !== "movie" && hasMovie) return "剧集库,本次含电影";
return null;
};
const t = options.target;
return (
<div className="grid max-w-4xl gap-6 lg:grid-cols-2">
<div className="space-y-6">
<Section title="整理方式">
<div role="radiogroup" aria-label="整理方式" className="space-y-1.5">
<Choice
checked={options.action === "copy"}
onSelect={() => set({ action: "copy" })}
title="复制"
hint="保留源文件,不影响做种"
/>
<Choice
checked={options.action === "move"}
onSelect={() => set({ action: "move" })}
disabled={hnrCount ? `${hnrCount} 项做种中且 H&R 未满足,不能移动` : null}
title="移动"
hint="入库后删源;跨卷时按复制 → 核验 → 删源"
/>
<Choice checked={false} onSelect={() => {}} disabled="还不支持硬链接" title="硬链接" />
</div>
</Section>
<Section title="目标已存在">
<div role="radiogroup" aria-label="目标已存在" className="space-y-1.5">
<Choice
checked={options.onExisting === "conflict"}
onSelect={() => set({ onExisting: "conflict" })}
title="交给待处理"
hint="该组不执行,冲突进待处理"
/>
<Choice
checked={options.onExisting === "skip"}
onSelect={() => set({ onExisting: "skip" })}
title="跳过"
hint="只跳过已存在的文件,其余照常整理"
/>
<Choice
checked={options.onExisting === "replace_with_verify"}
onSelect={() => set({ onExisting: "replace_with_verify" })}
title="替换"
hint="新文件核验通过后,旧文件移入回收站"
/>
</div>
</Section>
</div>
<div className="space-y-6">
<Section title="目标">
<div role="radiogroup" aria-label="目标" className="space-y-1.5">
<Choice
checked={t.mode === "auto"}
onSelect={() => set({ target: { mode: "auto" } })}
title="自动"
hint="已绑定的作品沿用原目录,否则按作品类型选库根"
/>
{libRoots.map((r) => (
<Choice
key={r.id}
checked={t.mode === "root" && t.rootId === r.id}
disabled={rootMismatch(r)}
onSelect={() => set({ target: { mode: "root", rootId: r.id } })}
title={r.name}
hint={
<span className="font-mono">
{r.hostPath}
{r.usage ? ` · 剩余 ${formatBytes(r.usage.freeBytes, 1)}` : ""}
</span>
}
/>
))}
<Choice
checked={t.mode === "dir"}
onSelect={() => set({ target: { mode: "dir", ref: t.mode === "dir" ? t.ref : { root: "", path: "" } } })}
title="指定目录"
hint="文件按模板命名后直接放进这个目录,不建路径绑定"
/>
{t.mode === "dir" ? (
<DirPicker
roots={roots}
value={t.ref.root ? t.ref : null}
onChange={(ref) => set({ target: { mode: "dir", ref } })}
className="max-h-64"
/>
) : null}
</div>
</Section>
<Section title="命名">
<div role="radiogroup" aria-label="命名" className="space-y-1.5">
<Choice
checked={options.naming === "template"}
onSelect={() => set({ naming: "template" })}
title="按命名模板"
hint={
<>
模板按 作品覆盖 → 库根 → 全局 取第一个,预览里显示实际来源。
<Link to="/console/settings#library/naming" className="ml-1 underline underline-offset-2">
去设置
</Link>
</>
}
/>
<Choice
checked={options.naming === "keep"}
onSelect={() => set({ naming: "keep" })}
title="保留原文件名"
hint="目录仍按模板或已有绑定"
/>
</div>
</Section>
<Section title="入库后">
<div className="flex items-start justify-between gap-3 rounded-xl border border-border px-3 py-2">
<span className="text-body">
刷新元数据
<span className="block text-meta text-muted-foreground">按字段刷新,锁定的字段不变</span>
</span>
<Switch
checked={options.refreshMetadata}
onCheckedChange={(v) => set({ refreshMetadata: v })}
aria-label="入库后刷新元数据"
/>
</div>
<p className="text-meta text-muted-foreground">同名字幕、NFO、图片随主文件一起处理,预览里列出。</p>
</Section>
</div>
</div>
);
}
@@ -0,0 +1,288 @@
import type { PublicOrganizationRecord } from "@umt/contracts";
import { ArrowRight, TriangleAlert } from "lucide-react";
import { Link } from "react-router";
import type { FileRoot, FilesOrganizeGroup } from "~/api/files.ts";
import { RECORD_STATUS_LABEL, RECORD_STATUS_TONE } from "~/api/records.ts";
import { StatusBadge } from "~/components/console/status-badge.tsx";
import { baseName, parentRel } from "~/components/files/model.ts";
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { cn, formatBytes } from "~/lib/utils.ts";
import {
displayPath,
EXCLUDE_REASON,
FILE_KIND,
fileHasProblem,
groupAllSkipped,
groupExecutable,
groupHasProblem,
nestSidecars,
type PreviewStats,
pageOf,
SKIP_REASON,
TEMPLATE_SOURCE,
} from "./model.ts";
import { PathSegments } from "./path-segments.tsx";
export type GroupTitle = (g: FilesOrganizeGroup) => string;
const ERROR_TEXT: Record<string, string> = {
plan_blocked: "目标已存在,按“交给待处理”不执行",
plan_stale: "源文件在预览后有变化",
approval_required: "移动需要审批或持续授权",
download_active: "做种中且 H&R 未满足,不能移动",
};
/** 服务端原因码(message 以 code: 开头)→ 文案 */
const REASON_PREFIX: Record<string, string> = {
library_root_offline: "目标库根离线",
root_unavailable: "目录离线",
not_writable: "目标不可写",
path_escape: "路径越界",
source_missing: "源文件不存在",
};
function errorText(e: { code: string; message: string }): string {
const m = /^([a-z_]+):\s*(.*)$/.exec(e.message ?? "");
if (m && REASON_PREFIX[m[1] as string]) return `${REASON_PREFIX[m[1] as string]}(${m[2] || m[1]})`;
return e.message || ERROR_TEXT[e.code] || e.code;
}
export function PreviewStatsBar({
stats,
onlyProblems,
onOnlyProblems,
}: {
stats: PreviewStats;
onlyProblems: boolean;
onOnlyProblems: (v: boolean) => void;
}) {
return (
<div className="flex flex-wrap items-center gap-1.5">
<StatusBadge>
{stats.groups} 组 · {stats.files} 个文件
</StatusBadge>
<StatusBadge tone={stats.executable ? "success" : "neutral"}>可执行 {stats.executable}</StatusBadge>
{stats.conflicts ? <StatusBadge tone="warning">冲突 {stats.conflicts}</StatusBadge> : null}
{stats.failed ? <StatusBadge tone="destructive">不能执行 {stats.failed}</StatusBadge> : null}
<div className="ml-auto flex items-center gap-2 text-caption text-muted-foreground">
<span aria-hidden>只看问题</span>
<Switch checked={onlyProblems} onCheckedChange={onOnlyProblems} aria-label="只看问题" />
</div>
</div>
);
}
function TargetLine({ g, roots }: { g: FilesOrganizeGroup; roots: FileRoot[] }) {
const first = g.files.find((f) => f.target)?.target;
if (!first) return null;
const dirAbs = parentRel(first);
const segs = g.targetSegments ?? [];
if (segs.length) {
const rel = segs.map((s) => s.text).join("/");
const prefixAbs = dirAbs.endsWith(`/${rel}`) ? dirAbs.slice(0, dirAbs.length - rel.length - 1) : null;
const root = prefixAbs ? roots.find((r) => r.hostPath === prefixAbs) : undefined;
return (
<PathSegments
prefix={root ? root.name : (prefixAbs ?? null)}
segments={segs}
className="rounded-lg bg-foreground/[0.03] px-2 py-1.5"
/>
);
}
return (
<p className="rounded-lg bg-foreground/[0.03] px-2 py-1.5 font-mono text-meta break-all text-foreground">
{displayPath(dirAbs, roots)}
</p>
);
}
type GroupFile = FilesOrganizeGroup["files"][number];
function FileLine({ f, roots, nested }: { f: GroupFile; roots: FileRoot[]; nested?: boolean }) {
const problem = fileHasProblem(f);
const badge =
f.action === "hold" || f.existing === "conflict" ? (
<StatusBadge tone="warning">冲突</StatusBadge>
) : f.action === "skip" ? (
<StatusBadge>跳过{f.skipReason ? ` · ${SKIP_REASON[f.skipReason] ?? f.skipReason}` : ""}</StatusBadge>
) : f.action === "replace" ? (
<StatusBadge tone="warning">替换</StatusBadge>
) : !f.target ? (
<StatusBadge>不入库</StatusBadge>
) : null;
return (
<li className={cn("min-w-0 py-1", nested && "pl-5")}>
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-meta">
<span className="shrink-0 text-muted-foreground">{FILE_KIND[f.kind] ?? f.kind}</span>
<span className="shrink-0 text-muted-foreground tabular-nums">{formatBytes(f.size, 1)}</span>
{badge}
</div>
<p className="truncate font-mono text-meta text-muted-foreground" title={f.source}>
{displayPath(f.source, roots)}
</p>
{f.target ? (
<p
className={cn(
"flex min-w-0 items-center gap-1 font-mono text-meta",
problem ? "text-warning" : "text-foreground",
)}
title={f.target}
>
<ArrowRight aria-hidden className="h-3 w-3 shrink-0" />
<span className="truncate">{baseName(f.target)}</span>
</p>
) : null}
</li>
);
}
export function PreviewGroupCard({
g,
title,
roots,
onlyProblems,
result,
record,
}: {
g: FilesOrganizeGroup;
title: string;
roots: FileRoot[];
onlyProblems: boolean;
/** 执行后:显示结果而不是计划状态 */
result?: boolean;
record?: PublicOrganizationRecord;
}) {
const size = g.files.reduce((s, f) => s + f.size, 0);
const nested = nestSidecars(g.files).filter(
(n) => !onlyProblems || fileHasProblem(n.main) || n.main.action === "skip" || n.sidecars.some(fileHasProblem),
);
let status: React.ReactNode;
if (result) {
status = g.error ? (
<StatusBadge tone="destructive">失败</StatusBadge>
) : record ? (
<StatusBadge tone={RECORD_STATUS_TONE[record.status]}>{RECORD_STATUS_LABEL[record.status]}</StatusBadge>
) : g.recordId ? (
<StatusBadge tone="success">已提交</StatusBadge>
) : (
<StatusBadge>未执行</StatusBadge>
);
} else {
status = groupExecutable(g) ? (
<StatusBadge tone="success">计划就绪</StatusBadge>
) : groupAllSkipped(g) && !g.error ? (
<StatusBadge>全部跳过</StatusBadge>
) : (
<StatusBadge tone="destructive">不能执行</StatusBadge>
);
}
return (
<section className="rounded-2xl border border-border">
<header className="flex flex-wrap items-center gap-2 px-3 py-2 [&:not(:last-child)]:border-b [&:not(:last-child)]:border-border/60">
<span className="min-w-0 truncate text-body font-medium">{title}</span>
{status}
{g.entity?.created ? <StatusBadge tone="warning">新建作品</StatusBadge> : null}
<span className="text-meta text-muted-foreground">
{result && record ? `${record.counts.succeeded}/${record.counts.total} 成功 · ` : ""}
{g.files.length} 个文件 · {formatBytes(size, 1)}
{g.action ? ` · ${g.action === "move" ? "移动" : "复制"}` : ""}
{g.naming ? ` · 命名:${TEMPLATE_SOURCE[g.naming.templateSource] ?? g.naming.templateSource}` : ""}
</span>
{result && g.recordId ? (
<Link className="ml-auto text-meta underline underline-offset-2" to={`/console/organize/${g.recordId}`}>
整理记录
</Link>
) : null}
</header>
<div className="space-y-2 px-3 py-2.5 empty:hidden">
{g.error ? (
<p className="flex items-start gap-1.5 text-caption text-destructive">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{errorText(g.error)}
</p>
) : null}
{record?.reason && result ? <p className="text-caption text-warning">{record.reason.message}</p> : null}
{g.warnings.map((w) => (
<p key={w} className="flex items-start gap-1.5 text-caption text-warning">
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" />
{w}
</p>
))}
{!result ? <TargetLine g={g} roots={roots} /> : null}
{g.truncated ? <p className="text-caption text-warning">文件过多,预览只列出前一部分。</p> : null}
{!result && nested.length ? (
<ul className="divide-y divide-border/50">
{nested.map((n) => (
<li key={n.main.source}>
<ul>
<FileLine f={n.main} roots={roots} />
{n.sidecars.map((s) => (
<FileLine key={s.source} f={s} roots={roots} nested />
))}
</ul>
</li>
))}
</ul>
) : null}
{!result && g.excluded?.length ? (
<p className="text-meta text-muted-foreground">
排除 {g.excluded.length} 个:
{g.excluded
.slice(0, 6)
.map((e) => `${baseName(e.source)}(${EXCLUDE_REASON[e.reason] ?? e.reason})`)
.join(",")}
{g.excluded.length > 6 ? "…" : ""}
</p>
) : null}
</div>
</section>
);
}
export function PreviewList({
groups,
titleOf,
roots,
onlyProblems,
page,
onPage,
}: {
groups: FilesOrganizeGroup[];
titleOf: GroupTitle;
roots: FileRoot[];
onlyProblems: boolean;
page: number;
onPage: (p: number) => void;
}) {
const shown = onlyProblems ? groups.filter(groupHasProblem) : groups;
const pg = pageOf(shown, page);
if (!shown.length)
return <p className="py-8 text-center text-caption text-muted-foreground">没有问题项。关闭“只看问题”查看全部。</p>;
return (
<div className="space-y-3">
{pg.items.map((g, i) => (
<PreviewGroupCard
key={`${g.mediaEntityId}-${g.contentUnitId}-${g.planId ?? i}`}
g={g}
title={titleOf(g)}
roots={roots}
onlyProblems={onlyProblems}
/>
))}
{pg.pages > 1 ? (
<div className="flex items-center justify-center gap-2 text-caption">
<Button size="sm" variant="outline" disabled={pg.page === 0} onClick={() => onPage(pg.page - 1)}>
上一页
</Button>
<span className="text-muted-foreground tabular-nums">
{pg.page + 1} / {pg.pages}
</span>
<Button size="sm" variant="outline" disabled={pg.page >= pg.pages - 1} onClick={() => onPage(pg.page + 1)}>
下一页
</Button>
</div>
) : null}
</div>
);
}
+104 -40
View File
@@ -17,9 +17,9 @@ import { ConfirmDialog } from "~/components/console/confirm-dialog.tsx";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx"; import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { PageHeader } from "~/components/console/page-header.tsx"; import { PageHeader } from "~/components/console/page-header.tsx";
import { StatusBadge } from "~/components/console/status-badge.tsx"; import { StatusBadge } from "~/components/console/status-badge.tsx";
import { DirTree } from "~/components/files/dir-tree.tsx"; import { DirPicker } from "~/components/files/dir-picker.tsx";
import { Checkbox } from "~/components/files/file-list.tsx"; import { Checkbox } from "~/components/files/file-list.tsx";
import { baseName, newIdempotencyKey } from "~/components/files/model.ts"; import { baseName, newIdempotencyKey, parentRel } from "~/components/files/model.ts";
import { Button, buttonVariants } from "~/components/ui/button.tsx"; import { Button, buttonVariants } from "~/components/ui/button.tsx";
import { import {
Dialog, Dialog,
@@ -55,6 +55,8 @@ export default function FilesTrashPage() {
const [purgeAll, setPurgeAll] = useState(false); const [purgeAll, setPurgeAll] = useState(false);
const [purgeIds, setPurgeIds] = useState<string[] | null>(null); const [purgeIds, setPurgeIds] = useState<string[] | null>(null);
const [confirmText, setConfirmText] = useState(""); const [confirmText, setConfirmText] = useState("");
/** 还原失败的项:id → 原因(原位置被占用时按钮换成"还原到…") */
const [failedIds, setFailedIds] = useState<Map<string, { code: string; message: string }>>(new Map());
const rows = trash.data?.pages.flatMap((p) => p.items); const rows = trash.data?.pages.flatMap((p) => p.items);
const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]); const rootName = useMemo(() => new Map((roots.data ?? []).map((r) => [r.id, r])), [roots.data]);
const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? []; const expired = rows?.filter((r) => Date.parse(r.expiresAt) <= Date.now()) ?? [];
@@ -74,12 +76,25 @@ export default function FilesTrashPage() {
onSuccess: (r) => { onSuccess: (r) => {
setPicked(new Set()); setPicked(new Set());
setRestoreTo(null); setRestoreTo(null);
if (r.restored.length) toast.success(`已还原 ${r.restored.length} 项`); setFailedIds((prev) => {
for (const f of r.failed) const next = new Map(prev);
toast.error( for (const x of r.restored) next.delete(x.id);
f.code === "exists" || f.code === "conflict" ? "原位置已被占用。用“还原到…”换个目录" : f.message, for (const f of r.failed) next.set(f.id, { code: f.code, message: f.message });
{ description: rows?.find((x) => x.id === f.id)?.originalPath }, return next;
});
if (r.restored.length)
toast.success(
`已还原 ${r.restored.length} 项`,
r.recordId
? {
action: {
label: "整理记录",
onClick: () => window.location.assign(`/console/organize/${r.recordId}`),
},
}
: undefined,
); );
if (r.failed.length) toast.error(`${r.failed.length} 项没有还原,原因见列表`);
}, },
onError: (e) => toast.error(filesErrorText(e, "还原失败")), onError: (e) => toast.error(filesErrorText(e, "还原失败")),
}, },
@@ -115,28 +130,27 @@ export default function FilesTrashPage() {
}, },
{ {
key: "name", key: "name",
header: "原位置", header: "名称",
cell: (r) => { cell: (r) => {
const root = rootName.get(r.rootId); const fail = failedIds.get(r.id);
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null } | null;
return ( return (
<div className="flex min-w-0 max-w-lg items-start gap-2"> <div className="flex min-w-0 max-w-md items-start gap-2">
{r.isDir ? ( {r.isDir ? (
<Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> <Folder className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
) : ( ) : (
<FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> <FileIcon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)} )}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-foreground" title={r.originalPath}> <p className="truncate text-foreground" title={r.trashPath}>
{baseName(r.originalPath)} {baseName(r.originalPath)}
</p> </p>
<p className="truncate font-mono text-meta text-muted-foreground" title={r.trashPath}> {fail ? (
{root?.name ?? r.rootId} / {r.originalPath} <p className="text-meta text-destructive">
</p> {fail.code === "exists" || fail.code === "conflict"
{snap?.title ? ( ? "原位置已被占用"
<p className="text-meta text-muted-foreground"> : fail.code === "too_large"
删除时关联:{snap.title} ? "跨卷还原超过大小上限"
{snap.contentUnit ? ` ${snap.contentUnit}` : ""}(还原后资产不会自动恢复) : fail.message}
</p> </p>
) : null} ) : null}
</div> </div>
@@ -144,6 +158,26 @@ export default function FilesTrashPage() {
); );
}, },
}, },
{
key: "origin",
header: "原位置",
hideBelow: "md",
cell: (r) => {
const root = rootName.get(r.rootId);
const dir = parentRel(r.originalPath);
return root ? (
<Link
to={`/console/files?root=${encodeURIComponent(r.rootId)}${dir ? `&path=${encodeURIComponent(dir)}` : ""}`}
className="block max-w-xs truncate font-mono text-meta text-muted-foreground hover:text-foreground hover:underline"
title={`${root.hostPath}/${r.originalPath}`}
>
{dir ? `${root.name}/${dir}` : root.name}
</Link>
) : (
<span className="block max-w-xs truncate font-mono text-meta text-muted-foreground">{r.rootId}</span>
);
},
},
{ {
key: "size", key: "size",
header: "大小", header: "大小",
@@ -176,19 +210,50 @@ export default function FilesTrashPage() {
</span> </span>
), ),
}, },
{
key: "asset",
header: "关联资产",
hideBelow: "lg",
cell: (r) => {
const snap = r.assetSnapshot as { title?: string; contentUnit?: string | null; mediaEntityId?: string } | null;
if (!snap?.title) return <span className="text-muted-foreground">—</span>;
const label = `${snap.title}${snap.contentUnit ? ` ${snap.contentUnit}` : ""}`;
return (
<span className="block max-w-48 text-meta" title="删除时已退役;还原不会自动恢复资产">
{snap.mediaEntityId ? (
<Link className="underline underline-offset-2" to={`/console/library/${snap.mediaEntityId}`}>
{label}
</Link>
) : (
label
)}
<span className="block text-muted-foreground">已退役</span>
</span>
);
},
},
{ {
key: "act", key: "act",
header: <span className="sr-only">操作</span>, header: <span className="sr-only">操作</span>,
align: "right", align: "right",
cell: (r) => ( cell: (r) => (
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}> {failedIds.get(r.id)?.code === "exists" || failedIds.get(r.id)?.code === "conflict" ? (
<RotateCcw /> <Button size="xs" variant="outline" onClick={() => setRestoreTo([r.id])}>
还原 <RotateCcw />
</Button> 还原到…
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}> </Button>
还原到… ) : (
</Button> <>
<Button size="xs" variant="outline" onClick={() => doRestore([r.id])} disabled={restore.isPending}>
<RotateCcw />
还原
</Button>
<Button size="xs" variant="ghost" onClick={() => setRestoreTo([r.id])}>
还原到…
</Button>
</>
)}
{isAdmin ? ( {isAdmin ? (
<Button <Button
size="xs" size="xs"
@@ -209,13 +274,13 @@ export default function FilesTrashPage() {
<> <>
<PageHeader <PageHeader
title="回收站" title="回收站"
description="删除的文件在这里保留到期后自动清除,到期前可以还原。" description="到期后自动清除,到期前可以还原。还原不会恢复资产状态,需要时重新整理。"
actions={ actions={
<> <>
{filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null} {filesMockEnabled() ? <StatusBadge tone="warning">演示数据</StatusBadge> : null}
<Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}> <Link to="/console/files" className={buttonVariants({ variant: "outline", size: "sm" })}>
<ArrowLeft /> <ArrowLeft />
文件 文件管理
</Link> </Link>
</> </>
} }
@@ -228,13 +293,13 @@ export default function FilesTrashPage() {
onClick={() => doRestore([...picked])} onClick={() => doRestore([...picked])}
> >
{restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />} {restore.isPending ? <LoaderCircle className="animate-spin" /> : <RotateCcw />}
还原选中 {picked.size || ""} {picked.size ? `还原 ${picked.size} 项` : "还原选中"}
</Button> </Button>
{isAdmin ? ( {isAdmin ? (
<> <>
<Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}> <Button size="sm" variant="outline" disabled={!picked.size} onClick={() => setPurgeIds([...picked])}>
<Trash2 /> <Trash2 />
清除选中 {picked.size ? `清除 ${picked.size} 项` : "清除选中"}
</Button> </Button>
<ConfirmDialog <ConfirmDialog
trigger={ trigger={
@@ -274,7 +339,7 @@ export default function FilesTrashPage() {
hasMore={trash.hasNextPage} hasMore={trash.hasNextPage}
onLoadMore={() => void trash.fetchNextPage()} onLoadMore={() => void trash.fetchNextPage()}
loadingMore={trash.isFetchingNextPage} loadingMore={trash.isFetchingNextPage}
empty={{ icon: Trash2, title: "回收站是空的", description: "在文件页删除的项会出现在这里。" }} empty={{ icon: Trash2, title: "回收站是空的", description: "在文件管理里删除的项会出现在这里。" }}
/> />
<ConfirmDialog <ConfirmDialog
@@ -287,7 +352,7 @@ export default function FilesTrashPage() {
onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))} onConfirm={() => doPurge(purgeIds ?? undefined).then(() => setPurgeIds(null))}
/> />
{/* 清空全部:二次确认,需输入“清空回收站” */} {/* 清空全部:二次确认,需输入“清空” */}
<ConfirmDialog <ConfirmDialog
open={purgeAll} open={purgeAll}
onOpenChange={(o) => { onOpenChange={(o) => {
@@ -297,10 +362,11 @@ export default function FilesTrashPage() {
title="清空回收站?" title="清空回收站?"
description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`} description={`全部 ${rows?.length ?? 0} 项(含未到期)将被清除,不可恢复。`}
confirmLabel="清空" confirmLabel="清空"
confirmDisabled={confirmText.trim() !== "清空"}
destructive destructive
onConfirm={() => { onConfirm={() => {
if (confirmText.trim() !== "清空回收站") { if (confirmText.trim() !== "清空") {
toast.error("输入“清空回收站”以确认"); toast.error("输入“清空”以确认");
return Promise.reject(new Error("unconfirmed")); return Promise.reject(new Error("unconfirmed"));
} }
return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText("")); return doPurge(rows?.map((r) => r.id)).then(() => setConfirmText(""));
@@ -309,8 +375,8 @@ export default function FilesTrashPage() {
<Input <Input
value={confirmText} value={confirmText}
onChange={(e) => setConfirmText(e.target.value)} onChange={(e) => setConfirmText(e.target.value)}
placeholder="输入 清空回收站" placeholder="输入 清空"
aria-label="输入 清空回收站 以确认" aria-label="输入 清空 以确认"
/> />
</ConfirmDialog> </ConfirmDialog>
@@ -344,11 +410,9 @@ function RestoreToDialog({
<DialogContent className="sm:max-w-lg"> <DialogContent className="sm:max-w-lg">
<DialogHeader> <DialogHeader>
<DialogTitle>还原到…</DialogTitle> <DialogTitle>还原到…</DialogTitle>
<DialogDescription>{ids.length} 项 · 选一个可写目录。同名项已存在时跳过,不覆盖。</DialogDescription> <DialogDescription>{ids.length} 项 · 选一个可写库根下的目录。同名项已存在时不覆盖。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-80 overflow-y-auto rounded-xl border border-border p-1.5"> <DirPicker roots={roots.data ?? []} value={target} onChange={setTarget} className="max-h-80" />
<DirTree roots={roots.data ?? []} current={target} onSelect={setTarget} pickOnly />
</div>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={onClose}> <Button variant="outline" onClick={onClose}>
取消 取消
File diff suppressed because it is too large. Load diff