// 文件管理页的纯逻辑:状态文案、选择模型、键盘导航、路径工具。无 React 依赖,单测覆盖(model.test.ts)。 import type { FileEntry, FileIdentifyItem, FileRoot, FileStatus, FilesIdentifyResult, IdentifyCompletedPayload, } 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): 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" | "writeNfo" | "probe" | "delete" | "copyPath" | "openMedia" | "records"; /** 返回 null = 可用;否则为禁用原因 */ export function opDisabledReason(op: FileOp, e: FileEntry, root: Pick): 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 "writeNfo": if (root.kind === "library" && !root.writable) return "只读目录,不写 NFO"; if (e.status === "temp") return "未完成的文件不能刮削"; return 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; 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); } /** * 移动 / 复制目标的前端预检(与服务端 transfer 校验同口径,QA U13):目标就是源所在目录, * 或目标在源目录之内时直接提示,不发预览请求。 */ export function transferTargetProblem( items: { root: string; path: string }[], target: { root: string; path: string }, ): string | null { for (const it of items) { if (it.root !== target.root) continue; if (target.path === it.path || target.path.startsWith(`${it.path}/`)) return `目标在源目录之内(${baseName(it.path)}),换一个目录`; if (parentRel(it.path) === target.path) return `${baseName(it.path)} 已在这个目录里,换一个目录`; } return null; } 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(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" | "writeNfo" | "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 "writeNfo": // SCR-2:照 MP,任意视频文件或目录都能刮削(不要求已登记) 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): 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("writeNfo", e)) add("writeNfo", why("writeNfo")); 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); if (all("writeNfo")) add("writeNfo", why("writeNfo")); sep(); add("delete", why("delete")); return out; } /** * 行内 hover / 键盘聚焦时浮出的常用操作(桌面,§5.1;照 MP 的悬停图标,但只露 2–3 个,其余留在 ⋯ 菜单): * 已入库 → 打开作品、刷新元数据、重命名;未入库视频 / 目录 → 整理、识别、刮削; * 其余能识别的(临时文件里的视频等)→ 识别、重命名、移动到;别的文件 → 重命名、移动到。 */ export function hoverOps(e: FileEntry): MenuKey[] { if (e.asset) return ["openMedia", "scrape", "rename"]; if (opApplies("organize", e)) return ["organize", "identify", "writeNfo"]; if (opApplies("identify", e)) return ["identify", "rename", "move"]; return ["rename", "move"]; } /** 行内图标按钮的文字(tooltip / aria-label):菜单里的"…"表示还要再选一步,图标上不带 */ export function hoverOpLabel(op: MenuKey): string { return OP_LABEL[op].replace("…", ""); } export const OP_LABEL: Record = { open: "打开", organize: "整理…", identify: "识别", rename: "重命名", move: "移动到…", copy: "复制到…", scrape: "刷新元数据", writeNfo: "刮削…", probe: "探测", copyPath: "复制路径…", openMedia: "打开作品", records: "查看整理记录", delete: "删除", }; /** 名称列副行(§4.2):已登记 → 作品 (年) · 季集;待识别视频 → 服务端轻量解析 parsedHint(G7);目录 → 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 ? ` · ${s.registeredCount} 个已入库` : s.videoCount ? " · 未入库" : ""}`, tone: "meta", }; } if (e.parsedHint) return { text: e.parsedHint, tone: "hint" }; return null; } export const ROOT_KIND_LABEL: Record = { download: "下载目录", library: "媒体库" }; export type Crumb = { label: string; path: string }; /** * 面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)+ 登记目录名 + 各级子目录。 * group 不可点;crumbs 每段可点(path 为登记目录内相对路径,根为 "")。 */ export function breadcrumbItems( root: Pick, path: string, ): { group: string; crumbs: Crumb[] } { const segs = segments(path); return { group: ROOT_KIND_LABEL[root.kind], crumbs: [ { label: root.name, path: "" }, ...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })), ], }; } export type Loc = { root: string; path: string }; /** 初始位置(§2 #2):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */ export function initialLocation(roots: Pick[], 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>( 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); } // ─── 识别:远端结果回填(S-7) ──────────────────────────────────── const identifyKey = (it: Pick) => `${it.root}\u0000${it.path}`; /** 服务端给"远端在后台查"项加的提示;结果回来后去掉 */ const REMOTE_PENDING_WARNING = /^远端候选在后台查询/; /** * SSE identify.completed 回填:taskId 与响应的 remoteTaskId 一致时,按 root + path 用完整结果替换条目; * 失败时保留本地结果,把"后台查询中"提示换成失败原因。不相关的事件原样返回。 */ export function applyIdentifyCompleted(r: FilesIdentifyResult, p: IdentifyCompletedPayload): FilesIdentifyResult { if (!r.remoteTaskId || p.taskId !== r.remoteTaskId) return r; if (p.status === "failed") { const note = `远端查询失败,只有本地结果${p.error ? `:${p.error.slice(0, 200)}` : ""}`; return { ...r, remoteTaskId: null, remotePending: 0, items: r.items.map((it) => it.warnings.some((w) => REMOTE_PENDING_WARNING.test(w)) ? { ...it, warnings: [...it.warnings.filter((w) => !REMOTE_PENDING_WARNING.test(w)), note] } : it, ), }; } const by = new Map(p.items.map((it) => [identifyKey(it), it])); return { ...r, remoteTaskId: null, remotePending: 0, items: r.items.map((it) => by.get(identifyKey(it)) ?? it), }; } // ── 预览(FM-2)── export type PreviewKind = "text" | "subtitle" | "torrent" | "image"; const PREVIEW_EXT: Record = {}; for (const e of "nfo txt log md markdown json xml yaml yml ini conf cfg csv tsv cue sfv md5 sha1 sha256 url".split(" ")) PREVIEW_EXT[e] = "text"; for (const e of "srt vtt ass ssa sup".split(" ")) PREVIEW_EXT[e] = "subtitle"; for (const e of "jpg jpeg png webp avif gif bmp".split(" ")) PREVIEW_EXT[e] = "image"; PREVIEW_EXT.torrent = "torrent"; /** 与服务端 preview-parse.ts 同一张扩展名表;目录、链接、无扩展名返回 null */ export function previewKindOf(e: Pick): PreviewKind | null { if (e.type !== "file" || !e.ext) return null; return PREVIEW_EXT[e.ext.toLowerCase()] ?? null; } /** 毫秒 → 00:01:02.345 */ export function cueTime(ms: number): string { const h = Math.floor(ms / 3_600_000); const m = Math.floor((ms % 3_600_000) / 60_000); const s = Math.floor((ms % 60_000) / 1000); const f = ms % 1000; const p = (n: number, w = 2) => String(n).padStart(w, "0"); return `${p(h)}:${p(m)}:${p(s)}.${p(f, 3)}`; } /** JSON 文本美化;解析失败或已截断时原样返回 */ export function prettyJson(text: string, truncated: boolean): string { if (truncated) return text; try { return JSON.stringify(JSON.parse(text), null, 2); } catch { return text; } }