diff --git a/apps/web/app/components/files/file-list.tsx b/apps/web/app/components/files/file-list.tsx index 300878b0..fe4b9f74 100644 --- a/apps/web/app/components/files/file-list.tsx +++ b/apps/web/app/components/files/file-list.tsx @@ -13,6 +13,7 @@ import { MoreHorizontal, Music, PenLine, + RefreshCw, Sparkles, Subtitles, Wand2, @@ -28,10 +29,10 @@ import { clickSelect, entryStatusView, entrySubline, + hoverOpLabel, hoverOps, type MenuKey, moveFocus, - OP_LABEL, type Selection, selectAll, toggleSelect, @@ -40,6 +41,8 @@ import { const HOVER_ICON: Partial> = { organize: Wand2, identify: Sparkles, + writeNfo: FileText, + scrape: RefreshCw, rename: PenLine, move: FolderInput, openMedia: Library, @@ -228,7 +231,8 @@ export function FileList({ : []; const onKeyDown = (ev: React.KeyboardEvent) => { - if ((ev.target as HTMLElement).closest("input,textarea,[role=menu]")) return; + // 行内操作按钮获焦时(Tab 进入)Enter / Space 交给按钮本身 + if ((ev.target as HTMLElement).closest("input,textarea,[role=menu],[data-hover-op]")) return; const focused = selection.focus ? byKey.get(selection.focus) : undefined; const mod = ev.ctrlKey || ev.metaKey; const step = (delta: number) => { @@ -284,6 +288,17 @@ export function FileList({ } return; } + case "Tab": { + // Tab 进入焦点行的行内操作(列表里的链接不打断);Shift+Tab 照常离开列表 + if (ev.shiftKey || !focused) return; + // 按行 id 找(行键里有 \0,CSS.escape 会把它换成 U+FFFD,属性选择器匹配不上) + const op = ref.current?.querySelector(`#file-row-${focusIndex} [data-hover-op]`); + if (op) { + ev.preventDefault(); + op.focus(); + } + return; + } case "Escape": if (selection.keys.size) { ev.preventDefault(); @@ -294,7 +309,7 @@ export function FileList({ case "F10": if (focused && (ev.key === "ContextMenu" || ev.shiftKey)) { ev.preventDefault(); - const row = ref.current?.querySelector(`[data-row-key="${CSS.escape(keyOf(focused))}"]`); + const row = ref.current?.querySelector(`#file-row-${focusIndex}`); const r = row?.getBoundingClientRect(); onMenu(focused, { x: (r?.left ?? 0) + 48, y: (r?.bottom ?? 0) - 4 }); } @@ -355,7 +370,7 @@ export function FileList({ onFocus={() => { if (!selection.focus && order[0]) onSelection({ ...selection, focus: order[0] }); }} - className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset" + className="group/grid relative min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:ring-inset" > {!mobile ? ( // biome-ignore lint/a11y/useSemanticElements: 同上,虚拟网格的表头行 @@ -401,6 +416,7 @@ export function FileList({ const selected = selection.keys.has(k); const focused = selection.focus === k; const isDropDir = e.type === "dir" && !e.escape; + const hasOps = !!onAction && !e.escape; const common = { id: `file-row-${i}`, role: "row", @@ -512,10 +528,31 @@ export function FileList({ - {sizeCell(e)} - {relTime(e.mtime)} + {/* 行内操作浮出时让出位置:lg 起盖在修改时间上,更窄时盖在大小上 */} + + {sizeCell(e)} + + + {relTime(e.mtime)} + - {onAction && !e.escape ? : null} + {hasOps && onAction ? ( + + ) : null} {menuBtn} @@ -559,13 +596,19 @@ export const DRAG_MIME = "application/x-umt-files"; const DESKTOP_COLS = "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),盖在大小/时间列上方 */ +/** + * 行内常用操作(§5.1,照 MP 悬停露出图标):鼠标悬停该行,或键盘焦点在该行(列表获焦且是键盘操作)时, + * 在行尾浮出 2–3 个图标按钮,盖在修改时间列上;每个带 tooltip 与 aria-label,被状态阻止的灰显并写原因。 + * 焦点行的按钮可用 Tab 进入。窄屏走卡片 + ⋯ 菜单,不依赖悬停。 + */ function HoverOps({ e, + active, onAction, opState, }: { e: FileEntry; + active: boolean; onAction: (e: FileEntry, op: MenuKey) => void; opState?: (e: FileEntry, op: MenuKey) => string | null; }) { @@ -573,34 +616,43 @@ function HoverOps({ return ( {ops.map((op) => { const Icon = HOVER_ICON[op]; + const label = hoverOpLabel(op); const why = opState?.(e, op) ?? null; return ( ); })} diff --git a/apps/web/app/components/files/model.test.ts b/apps/web/app/components/files/model.test.ts index 2d28510f..c279d5f3 100644 --- a/apps/web/app/components/files/model.test.ts +++ b/apps/web/app/components/files/model.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { resolveFilesMock } from "~/api/files.ts"; import { applyIdentifyCompleted, + breadcrumbItems, clickSelect, collapseCrumbs, cueTime, @@ -10,6 +11,7 @@ import { entryStatusView, entrySubline, formatEpisodes, + hoverOpLabel, hoverOps, initialLocation, menuModel, @@ -220,10 +222,45 @@ describe("v2 菜单与副行", () => { 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"]); + it("悬停常用操作按文件 / 目录类型选 2–3 个,都在菜单里能找到", () => { + expect(hoverOps(video)).toEqual(["organize", "identify", "writeNfo"]); + expect(hoverOps({ ...base, type: "dir" as const, fileKind: null })).toEqual(["organize", "identify", "writeNfo"]); + expect(hoverOps(registered)).toEqual(["openMedia", "scrape", "rename"]); + // 未完成的视频不能整理:退为识别 / 重命名 / 移动 + expect(hoverOps({ ...video, status: "temp" as const })).toEqual(["identify", "rename", "move"]); expect(hoverOps({ ...base, fileKind: "nfo" as const })).toEqual(["rename", "move"]); + expect(hoverOps({ ...base, fileKind: "subtitle" as const })).toEqual(["rename", "move"]); + for (const e of [video, registered, { ...base, fileKind: "nfo" as const }]) { + const ops = hoverOps(e); + expect(ops.length).toBeGreaterThanOrEqual(2); + expect(ops.length).toBeLessThanOrEqual(3); + const menu = menuModel([e], lib).flatMap((x) => ("separator" in x ? [] : [x.key])); + for (const op of ops) expect(menu).toContain(op); + } + }); + + it("悬停图标的 tooltip / aria-label 文字去掉菜单里的省略号", () => { + expect(hoverOpLabel("organize")).toBe("整理"); + expect(hoverOpLabel("writeNfo")).toBe("刮削"); + expect(hoverOpLabel("move")).toBe("移动到"); + expect(hoverOpLabel("scrape")).toBe("刷新元数据"); + expect(hoverOpLabel("openMedia")).toBe("打开作品"); + }); + + it("面包屑:登记目录类型 > 登记目录 > 各级子目录,每段带可跳转的相对路径", () => { + expect(breadcrumbItems({ name: "qBittorrent · 下载", kind: "download" }, "")).toEqual({ + group: "下载目录", + crumbs: [{ label: "qBittorrent · 下载", path: "" }], + }); + expect(breadcrumbItems({ name: "剧集库", kind: "library" }, "亚洲剧集/繁花 (2023)/Season 1/")).toEqual({ + group: "媒体库", + crumbs: [ + { label: "剧集库", path: "" }, + { label: "亚洲剧集", path: "亚洲剧集" }, + { label: "繁花 (2023)", path: "亚洲剧集/繁花 (2023)" }, + { label: "Season 1", path: "亚洲剧集/繁花 (2023)/Season 1" }, + ], + }); }); it("副行:已登记作品、待识别解析、目录计数", () => { diff --git a/apps/web/app/components/files/model.ts b/apps/web/app/components/files/model.ts index cd0ea344..68c4428f 100644 --- a/apps/web/app/components/files/model.ts +++ b/apps/web/app/components/files/model.ts @@ -429,13 +429,23 @@ export function menuModel(list: FileEntry[], root: Pick = { open: "打开", organize: "整理…", @@ -470,6 +480,28 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "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):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */ diff --git a/apps/web/app/components/files/path-breadcrumb.tsx b/apps/web/app/components/files/path-breadcrumb.tsx index 6e49f1de..8a153fc5 100644 --- a/apps/web/app/components/files/path-breadcrumb.tsx +++ b/apps/web/app/components/files/path-breadcrumb.tsx @@ -1,35 +1,38 @@ import { ChevronRight, HardDrive } from "lucide-react"; import { useState } from "react"; +import type { FileRoot } from "~/api/files.ts"; import { cn } from "~/lib/utils.ts"; -import { collapseCrumbs, segments } from "./model.ts"; +import { breadcrumbItems, collapseCrumbs } from "./model.ts"; /** - * 路径面包屑:根名 + 各段,可点击跳转;每段也是拖放目标(拖入 = 移动到该目录)。 - * 窄屏折叠为"根 / … / 父 / 当前"。 + * 路径面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)> 登记目录 > 各级子目录。 + * 目录各段可点击跳转,也是拖放目标(拖入 = 移动到该目录)。窄屏去掉类型,折叠为"根 / … / 父 / 当前"。 */ export function PathBreadcrumb({ - rootName, + root, path, onNavigate, onDropTo, className, }: { - rootName: string; + root: Pick; path: string; onNavigate: (path: string) => void; /** 有值时每段可作为拖放目标 */ onDropTo?: (path: string, e: React.DragEvent) => void; className?: string; }) { - const segs = segments(path); - const crumbs = [ - { label: rootName, path: "" }, - ...segs.map((s, i) => ({ label: s, path: segs.slice(0, i + 1).join("/") })), - ]; + const { group, crumbs } = breadcrumbItems(root, path); const [over, setOver] = useState(null); - const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => ( + const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string, withGroup = false) => (
    + {withGroup ? ( +
  1. + {group} + +
  2. + ) : null} {items.map((c, i) => { const last = i === items.length - 1; return ( @@ -85,7 +88,7 @@ export function PathBreadcrumb({ return ( ); diff --git a/apps/web/app/routes/console/files.tsx b/apps/web/app/routes/console/files.tsx index 27af4832..62c718e3 100644 --- a/apps/web/app/routes/console/files.tsx +++ b/apps/web/app/routes/console/files.tsx @@ -994,7 +994,7 @@ export default function FilesPage() {
    {searchBox}
    ) : root ? ( go({ root: root.id, path: p })} className="min-w-0 flex-1" @@ -1039,7 +1039,7 @@ export default function FilesPage() { {root ? ( go({ root: root.id, path: p })} onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)} diff --git a/docs/design/file-manager-v2.md b/docs/design/file-manager-v2.md index a36062c0..b6eed575 100644 --- a/docs/design/file-manager-v2.md +++ b/docs/design/file-manager-v2.md @@ -137,7 +137,9 @@ ### 5.1 行内 hover 操作(桌面) -按行状态只露 2 个 + ⋯:待识别视频/目录 → `整理` `识别`;已登记 → `重命名` `打开作品`;其他 → `重命名` `移动到`。 +鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未入库视频/目录 → `整理` `识别` `刮削`;已入库 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。 + +面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。 ### 5.2 右键 / ⋯ 菜单(单项,最终顺序)