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

+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();
});
});