feat(web): file manager v2 and shared organize wizard (FE-4)

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:26:41 +08:00
1 parent 09a6b0b5d3
commit c31df8b69a
24 files changed
+4820 -1607

No files matched your search

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