- 左树可折叠(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]>
491 lines
19 KiB
TypeScript
491 lines
19 KiB
TypeScript
// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。
|
|
import type { FileEntry, FileRoot, FileStatus } from "~/api/files.ts";
|
|
import type { StatusTone } from "~/components/console/status-badge.tsx";
|
|
|
|
// ─── 状态徽标 ────────────────────────────────────────────────────
|
|
|
|
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: "符号链接指向登记目录之外,所有操作已禁用" };
|
|
const s: FileStatus = e.status;
|
|
switch (s) {
|
|
case "temp":
|
|
return { label: "临时文件", tone: "neutral", hint: "下载器或整理过程中的未完成文件" };
|
|
case "downloading":
|
|
return {
|
|
label: e.download?.progress != null ? `下载中 ${Math.floor(e.download.progress * 100)}%` : "下载中",
|
|
tone: "neutral",
|
|
};
|
|
case "registered":
|
|
return { label: "已登记", tone: "success" };
|
|
case "seeding":
|
|
return e.download?.hnr === "unsatisfied"
|
|
? { label: "做种中·H&R 未满足", tone: "warning", hint: "H&R 满足前不能改名、删除或移动" }
|
|
: { label: "做种中", tone: "neutral" };
|
|
case "unidentified":
|
|
return { label: "待识别", tone: "warning" };
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export const FILTER_CHIPS = [
|
|
{ value: "", label: "全部" },
|
|
{ value: "video", label: "视频" },
|
|
{ value: "unidentified", label: "待识别" },
|
|
{ value: "registered", label: "已登记" },
|
|
{ value: "downloading", label: "下载中" },
|
|
{ value: "temp", label: "临时文件" },
|
|
] as const;
|
|
|
|
export const SORT_OPTIONS = [
|
|
{ value: "name", label: "名称" },
|
|
{ value: "mtime", label: "修改时间" },
|
|
{ value: "size", label: "大小" },
|
|
{ value: "status", label: "状态" },
|
|
] as const;
|
|
|
|
// ─── 禁用原因(菜单 tooltip 用) ───────────────────────────────────
|
|
|
|
export type FileOp =
|
|
| "open"
|
|
| "rename"
|
|
| "move"
|
|
| "copy"
|
|
| "organize"
|
|
| "identify"
|
|
| "scrape"
|
|
| "probe"
|
|
| "delete"
|
|
| "copyPath"
|
|
| "openMedia"
|
|
| "records";
|
|
|
|
/** 返回 null = 可用;否则为禁用原因 */
|
|
export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick<FileRoot, "kind" | "writable">): string | null {
|
|
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 "做种中,H&R 满足前不能改名";
|
|
if (e.status === "temp") return "临时文件不能改名";
|
|
return null;
|
|
case "move":
|
|
if (!rootWritable) return "只读目录,不能移出。可以复制";
|
|
if (hnrBlocked) return "做种中,H&R 满足前不能移动";
|
|
return null;
|
|
case "delete":
|
|
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 "只有视频或目录能整理";
|
|
return null;
|
|
case "scrape":
|
|
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":
|
|
return e.asset || e.status === "registered" ? null : "没有整理记录";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// ─── 选择模型(Shift 区间 / Ctrl 切换 / 全选) ─────────────────────
|
|
|
|
export type Selection = { keys: Set<string>; anchor: string | null; focus: string | null };
|
|
|
|
export const emptySelection = (): Selection => ({ keys: new Set(), anchor: null, focus: null });
|
|
|
|
/** 点击一行:plain 单选;ctrl 切换;shift 从锚点到当前的区间(与 ctrl 同按则并入) */
|
|
export function clickSelect(
|
|
sel: Selection,
|
|
order: string[],
|
|
key: string,
|
|
mod: { shift?: boolean; ctrl?: boolean },
|
|
): Selection {
|
|
if (mod.shift && sel.anchor && order.includes(sel.anchor)) {
|
|
const a = order.indexOf(sel.anchor);
|
|
const b = order.indexOf(key);
|
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
const range = order.slice(lo, hi + 1);
|
|
const keys = mod.ctrl ? new Set([...sel.keys, ...range]) : new Set(range);
|
|
return { keys, anchor: sel.anchor, focus: key };
|
|
}
|
|
if (mod.ctrl) {
|
|
const keys = new Set(sel.keys);
|
|
if (keys.has(key)) keys.delete(key);
|
|
else keys.add(key);
|
|
return { keys, anchor: key, focus: key };
|
|
}
|
|
return { keys: new Set([key]), anchor: key, focus: key };
|
|
}
|
|
|
|
/** Space:切换焦点行的勾选 */
|
|
export function toggleSelect(sel: Selection, key: string): Selection {
|
|
const keys = new Set(sel.keys);
|
|
if (keys.has(key)) keys.delete(key);
|
|
else keys.add(key);
|
|
return { keys, anchor: key, focus: key };
|
|
}
|
|
|
|
export function selectAll(order: string[], sel: Selection): Selection {
|
|
return { keys: new Set(order), anchor: sel.anchor ?? order[0] ?? null, focus: sel.focus ?? order[0] ?? null };
|
|
}
|
|
|
|
/** ↑↓ 移动焦点;shift 时从锚点扩选 */
|
|
export function moveFocus(sel: Selection, order: string[], delta: number, shift: boolean): Selection {
|
|
if (order.length === 0) return sel;
|
|
const cur = sel.focus ? order.indexOf(sel.focus) : -1;
|
|
const next = Math.max(0, Math.min(order.length - 1, cur < 0 ? (delta > 0 ? 0 : order.length - 1) : cur + delta));
|
|
const key = order[next] as string;
|
|
if (shift) return clickSelect(sel.anchor ? sel : { ...sel, anchor: sel.focus ?? key }, order, key, { shift: true });
|
|
return { ...sel, focus: key };
|
|
}
|
|
|
|
/** 列表刷新后丢掉已不存在的键 */
|
|
export function pruneSelection(sel: Selection, order: string[]): Selection {
|
|
const present = new Set(order);
|
|
const keys = new Set([...sel.keys].filter((k) => present.has(k)));
|
|
return {
|
|
keys,
|
|
anchor: sel.anchor && present.has(sel.anchor) ? sel.anchor : null,
|
|
focus: sel.focus && present.has(sel.focus) ? sel.focus : null,
|
|
};
|
|
}
|
|
|
|
// ─── 路径工具(根内相对路径,"" = 根) ─────────────────────────────
|
|
|
|
export function joinRel(dir: string, name: string): string {
|
|
return dir ? `${dir}/${name}` : name;
|
|
}
|
|
|
|
export function parentRel(path: string): string {
|
|
const i = path.lastIndexOf("/");
|
|
return i < 0 ? "" : path.slice(0, i);
|
|
}
|
|
|
|
export function baseName(path: string): string {
|
|
const i = path.lastIndexOf("/");
|
|
return i < 0 ? path : path.slice(i + 1);
|
|
}
|
|
|
|
export function segments(path: string): string[] {
|
|
return path ? path.split("/").filter(Boolean) : [];
|
|
}
|
|
|
|
/** 面包屑折叠:超过 max 段时保留首段(根)+ … + 最后 max-1 段 */
|
|
export function collapseCrumbs<T>(items: T[], max: number): (T | null)[] {
|
|
if (items.length <= max) return items;
|
|
return [items[0] as T, null, ...items.slice(items.length - (max - 1))];
|
|
}
|
|
|
|
/** 名称校验(前端先挡一遍,服务端 sanitizeSegment 才是准绳) */
|
|
export function nameProblem(name: string): string | null {
|
|
const n = name.trim();
|
|
if (!n) return "名称不能为空";
|
|
if (n === "." || n === "..") return "不能用 . 或 ..";
|
|
if (/[/\\]/.test(n)) return "名称不能包含 / 或 \\";
|
|
// biome-ignore lint/suspicious/noControlCharactersInRegex: 过滤控制字符
|
|
if (/[\u0000-\u001f]/.test(n)) return "名称不能包含控制字符";
|
|
if (n.length > 255) return "名称过长";
|
|
return null;
|
|
}
|
|
|
|
/** 改名时默认只选中主名(不含扩展名) */
|
|
export function stemRange(name: string, isDir: boolean): [number, number] {
|
|
if (isDir) return [0, name.length];
|
|
const i = name.lastIndexOf(".");
|
|
return [0, i > 0 ? i : name.length];
|
|
}
|
|
|
|
/** 选中项合计大小(目录不计) */
|
|
export function totalSize(entries: FileEntry[]): number {
|
|
return entries.reduce((s, e) => s + (e.type === "file" ? e.size : 0), 0);
|
|
}
|
|
|
|
export function newIdempotencyKey(): string {
|
|
if (typeof crypto !== "undefined" && "randomUUID" in crypto) return crypto.randomUUID();
|
|
return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
|
|
}
|
|
|
|
/** 集号偏移:episodes 全体加 n(n 可为负);结果出现小于 1 的集号返回 null(调用方标为问题,不静默截断) */
|
|
export function shiftEpisodes(eps: number[] | undefined, n: number): number[] | undefined | null {
|
|
if (!eps?.length) return eps;
|
|
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 */
|
|
export function parseEpisodes(text: string): number[] | null {
|
|
const t = text.trim();
|
|
if (!t) return [];
|
|
const out: number[] = [];
|
|
for (const part of t.split(/[,\s]+/)) {
|
|
const m = /^(\d{1,4})(?:-(\d{1,4}))?$/.exec(part);
|
|
if (!m) return null;
|
|
const a = Number(m[1]);
|
|
const b = m[2] ? Number(m[2]) : a;
|
|
if (b < a || b - a > 200) return null;
|
|
for (let i = a; i <= b; i++) out.push(i);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function formatEpisodes(eps: number[] | undefined | null): string {
|
|
if (!eps?.length) return "";
|
|
const sorted = [...eps].sort((a, b) => a - b);
|
|
const first = sorted[0] as number;
|
|
const last = sorted[sorted.length - 1] as number;
|
|
if (sorted.length > 1 && last - first === sorted.length - 1) return `${first}-${last}`;
|
|
return sorted.join(",");
|
|
}
|
|
|
|
export function contentUnitLabel(season?: number | null, episodes?: number[] | null): string {
|
|
if (season == null) return "";
|
|
const s = `S${String(season).padStart(2, "0")}`;
|
|
if (!episodes?.length) return s;
|
|
const sorted = [...episodes].sort((a, b) => a - b);
|
|
const e = (n: number) => `E${String(n).padStart(2, "0")}`;
|
|
return sorted.length > 1
|
|
? `${s}${e(sorted[0] as number)}-${e(sorted[sorted.length - 1] as number)}`
|
|
: `${s}${e(sorted[0] as number)}`;
|
|
}
|
|
|
|
/** 前后路径对照:按 / 段取公共前缀,差异部分单独高亮 */
|
|
export function splitCommon(a: string, b: string): { common: string; a: string; b: string } {
|
|
const sa = a.split("/");
|
|
const sb = b.split("/");
|
|
let i = 0;
|
|
while (i < sa.length - 1 && i < sb.length - 1 && sa[i] === sb[i]) i++;
|
|
const common = i ? `${sa.slice(0, i).join("/")}/` : "";
|
|
return { common, a: sa.slice(i).join("/"), b: sb.slice(i).join("/") };
|
|
}
|
|
|
|
/** 删除阻断原因文案;可通过勾选解除的返回 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 };
|
|
case "is_root":
|
|
return { text: "不能删除登记目录本身", soft: false };
|
|
case "needs_retire":
|
|
return { text: "含已登记资产,勾选“同时退役资产”后才能删除", soft: true };
|
|
case "needs_unbind":
|
|
return { text: "是作品或季的绑定目录,勾选“解除路径绑定”后才能删除", soft: true };
|
|
case "root_unavailable":
|
|
return { text: "目录离线,不能确认文件状态", soft: false };
|
|
case "not_found":
|
|
return { text: "文件已不存在", soft: false };
|
|
case "escape":
|
|
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);
|
|
}
|