feat(files): 行尾悬停露出常用操作图标,面包屑带登记目录类型
学 MP 文件管理:鼠标悬停或键盘焦点所在行在行尾浮出 2–3 个图标按钮(未入库视频 / 目录:整理、识别、刮削;已入库:打开作品、刷新元数据、重命名;其余:重命名、移动到),带 tooltip 与 aria-label,被状态阻止的灰显写原因;浮出时让出修改时间列;焦点行可 Tab 进入、Esc 回列表;窄屏仍只用 ⋯ 菜单。 面包屑前加「下载目录 / 媒体库」说明段。顺带修正 Shift+F10 菜单定位按行键找行时被 CSS.escape 改坏的问题。 Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
1 parent
d98899a5f8
commit
67f05895d3
6 files changed
+165
-39
No files matched your search
@@ -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<Record<MenuKey, LucideIcon>> = {
|
||||
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<HTMLElement>(`#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<HTMLElement>(`[data-row-key="${CSS.escape(keyOf(focused))}"]`);
|
||||
const row = ref.current?.querySelector<HTMLElement>(`#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({
|
||||
<Cell className="min-w-0 overflow-hidden">
|
||||
<EntryStatus e={e} />
|
||||
</Cell>
|
||||
<Cell className="truncate text-right text-caption tabular-nums">{sizeCell(e)}</Cell>
|
||||
<Cell className="hidden truncate text-caption text-muted-foreground lg:block">{relTime(e.mtime)}</Cell>
|
||||
{/* 行内操作浮出时让出位置:lg 起盖在修改时间上,更窄时盖在大小上 */}
|
||||
<Cell
|
||||
className={cn(
|
||||
"truncate text-right text-caption tabular-nums",
|
||||
hasOps && "max-lg:group-hover/row:invisible",
|
||||
hasOps &&
|
||||
focused &&
|
||||
"max-lg:group-focus-visible/grid:invisible max-lg:group-has-focus-visible/row:invisible",
|
||||
)}
|
||||
>
|
||||
{sizeCell(e)}
|
||||
</Cell>
|
||||
<Cell
|
||||
className={cn(
|
||||
"hidden truncate text-caption text-muted-foreground lg:block",
|
||||
hasOps && "group-hover/row:invisible",
|
||||
hasOps && focused && "group-focus-visible/grid:invisible group-has-focus-visible/row:invisible",
|
||||
)}
|
||||
>
|
||||
{relTime(e.mtime)}
|
||||
</Cell>
|
||||
<Cell className="relative grid place-items-center">
|
||||
{onAction && !e.escape ? <HoverOps e={e} onAction={onAction} opState={opState} /> : null}
|
||||
{hasOps && onAction ? (
|
||||
<HoverOps e={e} active={focused} onAction={onAction} opState={opState} />
|
||||
) : null}
|
||||
{menuBtn}
|
||||
</Cell>
|
||||
</div>
|
||||
@@ -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 (
|
||||
<span
|
||||
className={cn(
|
||||
// 只在鼠标悬浮时盖在大小/时间列上;选中/键盘聚焦的行不常驻(否则大小和时间一直被挡),
|
||||
// 键盘操作走右键菜单与快捷键。
|
||||
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-1 rounded-full bg-background/95 py-0.5 pl-1 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
|
||||
"group-hover/row:pointer-events-auto group-hover/row:opacity-100",
|
||||
"pointer-events-none absolute top-1/2 right-9 flex -translate-y-1/2 items-center gap-0.5 rounded-full bg-background/95 p-0.5 opacity-0 backdrop-blur shadow-sm ring-1 ring-border transition-opacity duration-micro",
|
||||
"group-hover/row:pointer-events-auto group-hover/row:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100",
|
||||
active && "group-focus-visible/grid:pointer-events-auto group-focus-visible/grid:opacity-100",
|
||||
)}
|
||||
>
|
||||
{ops.map((op) => {
|
||||
const Icon = HOVER_ICON[op];
|
||||
const label = hoverOpLabel(op);
|
||||
const why = opState?.(e, op) ?? null;
|
||||
return (
|
||||
<button
|
||||
key={op}
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
data-hover-op={op}
|
||||
tabIndex={active ? 0 : -1}
|
||||
aria-label={label}
|
||||
aria-disabled={!!why || undefined}
|
||||
title={why ?? OP_LABEL[op].replace("…", "")}
|
||||
title={why ? `${label} · ${why}` : label}
|
||||
onClick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!why) onAction(e, op);
|
||||
}}
|
||||
onDoubleClick={(ev) => ev.stopPropagation()}
|
||||
onKeyDown={(ev) => {
|
||||
// Esc / 在第一个按钮上 Shift+Tab:回到列表(焦点行不变)
|
||||
const first = ev.currentTarget.parentElement?.firstElementChild === ev.currentTarget;
|
||||
if (ev.key === "Escape" || (ev.key === "Tab" && ev.shiftKey && first)) {
|
||||
ev.preventDefault();
|
||||
ev.currentTarget.closest<HTMLElement>("[role=grid]")?.focus();
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
"inline-flex h-7 items-center gap-1 rounded-full px-2 text-meta whitespace-nowrap",
|
||||
"grid h-7 w-7 place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
|
||||
why ? "cursor-not-allowed text-muted-foreground/50" : "text-foreground hover:bg-foreground/[0.07]",
|
||||
)}
|
||||
>
|
||||
{Icon ? <Icon className="h-3.5 w-3.5" /> : null}
|
||||
{OP_LABEL[op].replace("…", "")}
|
||||
{Icon ? <Icon aria-hidden className="h-3.5 w-3.5" /> : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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("副行:已登记作品、待识别解析、目录计数", () => {
|
||||
|
||||
@@ -429,13 +429,23 @@ export function menuModel(list: FileEntry[], root: Pick<FileRoot, "kind" | "writ
|
||||
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"];
|
||||
/**
|
||||
* 行内 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<MenuKey, string> = {
|
||||
open: "打开",
|
||||
organize: "整理…",
|
||||
@@ -470,6 +480,28 @@ export function entrySubline(e: FileEntry): { text: string; tone: "link" | "hint
|
||||
return null;
|
||||
}
|
||||
|
||||
export const ROOT_KIND_LABEL: Record<FileRoot["kind"], string> = { download: "下载目录", library: "媒体库" };
|
||||
|
||||
export type Crumb = { label: string; path: string };
|
||||
|
||||
/**
|
||||
* 面包屑(照 MP「本地 > MEDIA > 亚洲剧集」):登记目录类型(下载目录 / 媒体库,只作说明)+ 登记目录名 + 各级子目录。
|
||||
* group 不可点;crumbs 每段可点(path 为登记目录内相对路径,根为 "")。
|
||||
*/
|
||||
export function breadcrumbItems(
|
||||
root: Pick<FileRoot, "name" | "kind">,
|
||||
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):上次位置(根在线)> 第一个在线下载目录 > 第一个在线库根 > 第一个 */
|
||||
|
||||
@@ -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<FileRoot, "name" | "kind">;
|
||||
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<string | null>(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) => (
|
||||
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
|
||||
{withGroup ? (
|
||||
<li className="flex shrink-0 items-center gap-0.5 pl-1.5 pr-0.5 text-body text-muted-foreground">
|
||||
{group}
|
||||
<ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0" />
|
||||
</li>
|
||||
) : null}
|
||||
{items.map((c, i) => {
|
||||
const last = i === items.length - 1;
|
||||
return (
|
||||
@@ -85,7 +88,7 @@ export function PathBreadcrumb({
|
||||
|
||||
return (
|
||||
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
|
||||
{render(crumbs, "full", "hidden md:flex")}
|
||||
{render(crumbs, "full", "hidden md:flex", true)}
|
||||
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
|
||||
</nav>
|
||||
);
|
||||
|
||||
@@ -994,7 +994,7 @@ export default function FilesPage() {
|
||||
<div className="min-w-0 flex-1">{searchBox}</div>
|
||||
) : root ? (
|
||||
<PathBreadcrumb
|
||||
rootName={root.name}
|
||||
root={root}
|
||||
path={path}
|
||||
onNavigate={(p) => go({ root: root.id, path: p })}
|
||||
className="min-w-0 flex-1"
|
||||
@@ -1039,7 +1039,7 @@ export default function FilesPage() {
|
||||
</Button>
|
||||
{root ? (
|
||||
<PathBreadcrumb
|
||||
rootName={root.name}
|
||||
root={root}
|
||||
path={path}
|
||||
onNavigate={(p) => go({ root: root.id, path: p })}
|
||||
onDropTo={(p, ev) => onDropTo({ root: root.id, path: p }, ev)}
|
||||
|
||||
@@ -137,7 +137,9 @@
|
||||
|
||||
### 5.1 行内 hover 操作(桌面)
|
||||
|
||||
按行状态只露 2 个 + ⋯:待识别视频/目录 → `整理` `识别`;已登记 → `重命名` `打开作品`;其他 → `重命名` `移动到`。
|
||||
鼠标悬停或键盘焦点所在行(Tab 进入按钮,Esc 回列表),在行尾浮出 2–3 个图标按钮(带 tooltip 与 aria-label,盖在修改时间列上),其余留在 ⋯:未入库视频/目录 → `整理` `识别` `刮削`;已入库 → `打开作品` `刷新元数据` `重命名`;未完成的视频 → `识别` `重命名` `移动到`;其他 → `重命名` `移动到`。窄屏卡片不依赖悬停,只用 ⋯。
|
||||
|
||||
面包屑照 MP「本地 > MEDIA > 亚洲剧集」:`下载目录 / 媒体库`(说明,不可点)> 登记目录 > 各级子目录;窄屏去掉类型并折叠。
|
||||
|
||||
### 5.2 右键 / ⋯ 菜单(单项,最终顺序)
|
||||
|
||||
|
||||
Reference in new issue
Block a user