feat(web): file manager v2 and shared organize wizard (FE-4)
- 左树可折叠(44px 图标条)、下载目录在前、上一级、初始位置记忆;列表名称副行(作品·季集 / 解析提示 / 目录计数)、状态列 120px - 行内 hover 2 个主操作;右键/⋯ 菜单按 v2 顺序,不适用隐藏、被阻止灰显带原因;批量栏"更多" - 详情抽屉:识别节(解析 + Top5 候选 + 按此整理)、整理记录节、版本目标、文本预览前 256 KB - 共享整理向导 components/organize-wizard:3 步(文件与作品表格/批量条 → 选项 → 预览并执行), TMDB 候选 external 就地建作品、最小体积、onExisting、命名来源、入库后刷新元数据、只看问题、分段着色、 plan_stale 自动重新预览、执行进度回查 /files/records;来源支持文件/下载任务/整理记录 - 重命名默认值取 suggest-name;批量刷新元数据走 scrape-batch;回收站条数、原位置链接、占用后改"还原到…" - 快捷键 Alt+↑、Ctrl/⌘+Enter、?;移动端单栏工具栏与底部批量栏 Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
09a6b0b5d3
commit
c31df8b69a
24 files changed
+4820
-1607
No files matched your search
+164
-11
@@ -1,11 +1,14 @@
|
|||||||
import { keepPreviousData, useInfiniteQuery, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { 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}`;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
Reference in new issue
Block a user