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,92 @@
|
||||
import { ChevronRight, HardDrive } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { cn } from "~/lib/utils.ts";
|
||||
import { collapseCrumbs, segments } from "./model.ts";
|
||||
|
||||
/**
|
||||
* 路径面包屑:根名 + 各段,可点击跳转;每段也是拖放目标(拖入 = 移动到该目录)。
|
||||
* 窄屏折叠为"根 / … / 父 / 当前"。
|
||||
*/
|
||||
export function PathBreadcrumb({
|
||||
rootName,
|
||||
path,
|
||||
onNavigate,
|
||||
onDropTo,
|
||||
className,
|
||||
}: {
|
||||
rootName: string;
|
||||
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 [over, setOver] = useState<string | null>(null);
|
||||
|
||||
const render = (items: ((typeof crumbs)[number] | null)[], key: string, extra?: string) => (
|
||||
<ol key={key} className={cn("flex min-w-0 items-center gap-0.5", extra)}>
|
||||
{items.map((c, i) => {
|
||||
const last = i === items.length - 1;
|
||||
return (
|
||||
<li
|
||||
key={c ? c.path || "__root" : `gap-${i}`}
|
||||
className={cn("flex min-w-0 items-center gap-0.5", last && "shrink")}
|
||||
>
|
||||
{i > 0 ? <ChevronRight aria-hidden className="h-3.5 w-3.5 shrink-0 text-muted-foreground" /> : null}
|
||||
{c === null ? (
|
||||
<span className="px-1 text-muted-foreground">…</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onNavigate(c.path)}
|
||||
aria-current={last ? "page" : undefined}
|
||||
onDragOver={
|
||||
onDropTo
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
|
||||
setOver(c.path);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={onDropTo ? () => setOver(null) : undefined}
|
||||
onDrop={
|
||||
onDropTo
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
setOver(null);
|
||||
onDropTo(c.path, e);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
title={c.label}
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 truncate rounded-md px-1.5 py-1 text-body transition-colors duration-micro",
|
||||
last
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground",
|
||||
over === c.path && "bg-foreground/[0.08] ring-1 ring-ring",
|
||||
)}
|
||||
>
|
||||
{i === 0 ? <HardDrive className="h-3.5 w-3.5 shrink-0" /> : null}
|
||||
<span className="truncate">{c.label}</span>
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
|
||||
return (
|
||||
<nav aria-label="当前路径" className={cn("min-w-0", className)}>
|
||||
{render(crumbs, "full", "hidden md:flex")}
|
||||
{render(collapseCrumbs(crumbs, 3), "short", "flex md:hidden")}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user