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

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