feat(web): W5-B file manager page /console/files
Roots + lazy dir tree, virtualized grid with status badges and asset/slot/ download links, breadcrumb, sort, glob search, hidden toggle, filter chips, paging and truncation notice; multi-select (Shift/Ctrl/all), action bar, context menu, drag-to-move with dryRun preview, keyboard navigation, offline (503) and empty/error states, mobile layout with tree drawer. Rename/move/copy/delete dialogs with dryRun previews and reference checks, 4-step organize wizard, trash view (restore/restore-to/purge, expiry). DEV-only ?mock=1 in-memory backend; smoke route list includes /console/files. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
2e2d56184c
commit
89ea68465e
17 files changed
+6269
-2
No files matched your search
@@ -0,0 +1,157 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
|
||||
export type MenuItem =
|
||||
| {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
/** 快捷键提示 */
|
||||
hint?: string;
|
||||
/** 禁用原因(显示为 title,并在行内小字说明) */
|
||||
disabled?: string | null;
|
||||
destructive?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
| { key: string; separator: true };
|
||||
|
||||
export type MenuAnchor = { x: number; y: number };
|
||||
|
||||
/**
|
||||
* 右键菜单 / 行 ⋯ 菜单共用的浮层(pg-float)。按坐标定位,越界自动翻转;
|
||||
* 点外面、Esc、滚动、失焦都会关闭;↑↓ 在可用项间移动,Enter 执行。
|
||||
*/
|
||||
export function ContextMenu({
|
||||
anchor,
|
||||
items,
|
||||
onClose,
|
||||
label = "操作",
|
||||
}: {
|
||||
anchor: MenuAnchor | null;
|
||||
items: MenuItem[];
|
||||
onClose: () => void;
|
||||
label?: string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [pos, setPos] = useState<{ left: number; top: number } | null>(null);
|
||||
const [active, setActive] = useState(-1);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!anchor || !ref.current) return;
|
||||
const r = ref.current.getBoundingClientRect();
|
||||
const vw = window.innerWidth;
|
||||
const vh = window.innerHeight;
|
||||
const left = anchor.x + r.width > vw - 8 ? Math.max(8, anchor.x - r.width) : anchor.x;
|
||||
const top = anchor.y + r.height > vh - 8 ? Math.max(8, anchor.y - r.height) : anchor.y;
|
||||
setPos({ left, top });
|
||||
ref.current.focus();
|
||||
}, [anchor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) return;
|
||||
const close = (e: Event) => {
|
||||
if (e.type === "mousedown" && ref.current?.contains(e.target as Node)) return;
|
||||
onClose();
|
||||
};
|
||||
window.addEventListener("mousedown", close);
|
||||
window.addEventListener("resize", close);
|
||||
window.addEventListener("blur", close);
|
||||
document.addEventListener("scroll", close, true);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", close);
|
||||
window.removeEventListener("resize", close);
|
||||
window.removeEventListener("blur", close);
|
||||
document.removeEventListener("scroll", close, true);
|
||||
};
|
||||
}, [anchor, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anchor) {
|
||||
setPos(null);
|
||||
setActive(-1);
|
||||
}
|
||||
}, [anchor]);
|
||||
|
||||
if (!anchor || typeof document === "undefined") return null;
|
||||
const actionable = items
|
||||
.map((it, i) => ({ it, i }))
|
||||
.filter(({ it }) => !("separator" in it) && !it.disabled)
|
||||
.map(({ i }) => i);
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (!actionable.length) return;
|
||||
const cur = actionable.indexOf(active);
|
||||
const next =
|
||||
e.key === "ArrowDown"
|
||||
? actionable[(cur + 1) % actionable.length]
|
||||
: actionable[(cur - 1 + actionable.length) % actionable.length];
|
||||
setActive(next ?? -1);
|
||||
} else if (e.key === "Enter" && active >= 0) {
|
||||
e.preventDefault();
|
||||
const it = items[active];
|
||||
if (it && !("separator" in it) && !it.disabled) {
|
||||
onClose();
|
||||
it.onSelect();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={ref}
|
||||
role="menu"
|
||||
aria-label={label}
|
||||
tabIndex={-1}
|
||||
onKeyDown={onKeyDown}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
style={{ left: pos?.left ?? anchor.x, top: pos?.top ?? anchor.y, visibility: pos ? "visible" : "hidden" }}
|
||||
className="pg-float fixed z-50 min-w-52 max-w-72 animate-pg-pop rounded-2xl p-1.5 outline-none"
|
||||
>
|
||||
{items.map((it, i) =>
|
||||
"separator" in it ? (
|
||||
<hr key={it.key} className="my-1 border-0 border-t border-border" />
|
||||
) : (
|
||||
<button
|
||||
key={it.key}
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-disabled={!!it.disabled}
|
||||
title={it.disabled ?? undefined}
|
||||
onMouseEnter={() => setActive(i)}
|
||||
onClick={() => {
|
||||
if (it.disabled) return;
|
||||
onClose();
|
||||
it.onSelect();
|
||||
}}
|
||||
className={cn(
|
||||
"flex w-full items-start gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-body transition-colors duration-micro",
|
||||
it.disabled
|
||||
? "cursor-not-allowed opacity-50"
|
||||
: it.destructive
|
||||
? "text-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
: "text-foreground hover:bg-foreground/[0.06]",
|
||||
active === i &&
|
||||
!it.disabled &&
|
||||
(it.destructive ? "bg-destructive/10 text-destructive" : "bg-foreground/[0.06]"),
|
||||
)}
|
||||
>
|
||||
{it.icon ? <it.icon className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" /> : <span className="w-4" />}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate">{it.label}</span>
|
||||
{it.disabled ? <span className="block text-meta text-muted-foreground">{it.disabled}</span> : null}
|
||||
</span>
|
||||
{it.hint ? <kbd className="mt-0.5 font-mono text-meta text-muted-foreground">{it.hint}</kbd> : null}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user