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,352 @@
|
||||
import { ChevronRight, Download, Folder, FolderOpen, HardDrive, LoaderCircle, TriangleAlert } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { type FileRoot, useDirChildren } from "~/api/files.ts";
|
||||
import { healthLabel } from "~/lib/labels.ts";
|
||||
import { cn, formatBytes } from "~/lib/utils.ts";
|
||||
import { joinRel, segments } from "./model.ts";
|
||||
|
||||
type Loc = { root: string; path: string };
|
||||
type DropHandler = (target: Loc, e: React.DragEvent) => void;
|
||||
|
||||
const nodeKey = (root: string, path: string) => `${root}\u0000${path}`;
|
||||
|
||||
/**
|
||||
* 根列表 + 懒加载目录树。按 库 / 下载 分组,每根带健康徽标与剩余空间;
|
||||
* 目录节点可作为拖放目标(onDropTo),并自动展开到当前路径。
|
||||
* pickOnly:目标选择器模式(移动/复制到…),不可写根整根禁用。
|
||||
*/
|
||||
export function DirTree({
|
||||
roots,
|
||||
current,
|
||||
onSelect,
|
||||
onDropTo,
|
||||
pickOnly = false,
|
||||
defaultOpen = [],
|
||||
className,
|
||||
}: {
|
||||
roots: FileRoot[];
|
||||
current: Loc | null;
|
||||
onSelect: (loc: Loc) => void;
|
||||
onDropTo?: DropHandler;
|
||||
pickOnly?: boolean;
|
||||
/** 初始展开的根 id(目标选择器里默认展开可写根) */
|
||||
defaultOpen?: string[];
|
||||
className?: string;
|
||||
}) {
|
||||
const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen.map((id) => nodeKey(id, ""))));
|
||||
|
||||
// 当前路径的各级祖先自动展开
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
const segs = segments(current.path);
|
||||
next.add(nodeKey(current.root, ""));
|
||||
for (let i = 1; i < segs.length; i++) next.add(nodeKey(current.root, segs.slice(0, i).join("/")));
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, [current]);
|
||||
|
||||
const toggle = (k: string) =>
|
||||
setOpen((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(k)) next.delete(k);
|
||||
else next.add(k);
|
||||
return next;
|
||||
});
|
||||
|
||||
const groups = [
|
||||
{ title: "媒体库", items: roots.filter((r) => r.kind === "library") },
|
||||
{ title: "下载目录", items: roots.filter((r) => r.kind === "download") },
|
||||
].filter((g) => g.items.length > 0);
|
||||
|
||||
return (
|
||||
<nav aria-label="根与目录" className={cn("space-y-4", className)}>
|
||||
{groups.map((g) => (
|
||||
<section key={g.title} aria-label={g.title}>
|
||||
<p className="mb-1 px-2 text-micro font-semibold tracking-[0.15em] text-muted-foreground uppercase">
|
||||
{g.title}
|
||||
</p>
|
||||
<div className="space-y-0.5">
|
||||
{g.items.map((r) => (
|
||||
<RootNode
|
||||
key={r.id}
|
||||
root={r}
|
||||
current={current}
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onDropTo={onDropTo}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function RootNode({
|
||||
root,
|
||||
current,
|
||||
open,
|
||||
toggle,
|
||||
onSelect,
|
||||
onDropTo,
|
||||
pickOnly,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
current: Loc | null;
|
||||
open: Set<string>;
|
||||
toggle: (k: string) => void;
|
||||
onSelect: (loc: Loc) => void;
|
||||
onDropTo?: DropHandler;
|
||||
pickOnly: boolean;
|
||||
}) {
|
||||
const k = nodeKey(root.id, "");
|
||||
const offline = root.health === "offline";
|
||||
const disabled = pickOnly && (!root.writable || offline);
|
||||
const isOpen = open.has(k) && !offline;
|
||||
const active = current?.root === root.id && current.path === "";
|
||||
const h = healthLabel(root.health);
|
||||
const Icon = root.kind === "download" ? Download : HardDrive;
|
||||
return (
|
||||
<div>
|
||||
<TreeRow
|
||||
depth={0}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
expandable={!offline}
|
||||
expanded={isOpen}
|
||||
onToggle={() => toggle(k)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
onSelect({ root: root.id, path: "" });
|
||||
if (!open.has(k)) toggle(k);
|
||||
}}
|
||||
onDrop={onDropTo && root.writable && !offline ? (e) => onDropTo({ root: root.id, path: "" }, e) : undefined}
|
||||
title={disabled ? (offline ? "根离线" : "只读根,不能作为目标") : root.hostPath}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-body text-foreground">{root.name}</span>
|
||||
<span className="flex items-center gap-1.5 text-meta text-muted-foreground">
|
||||
{root.health !== "healthy" ? (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1",
|
||||
h.tone === "destructive" && "text-destructive",
|
||||
h.tone === "warning" && "text-warning",
|
||||
)}
|
||||
>
|
||||
{h.tone !== "neutral" ? <TriangleAlert className="h-3 w-3" /> : null}
|
||||
{h.label}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-success">
|
||||
<span aria-hidden className="h-1.5 w-1.5 rounded-full bg-success" />
|
||||
正常
|
||||
</span>
|
||||
)}
|
||||
{root.usage ? <span className="truncate">剩余 {formatBytes(root.usage.freeBytes, 1)}</span> : null}
|
||||
{!root.writable ? <span>只读</span> : null}
|
||||
</span>
|
||||
</span>
|
||||
</TreeRow>
|
||||
{isOpen ? (
|
||||
<Children
|
||||
root={root}
|
||||
path=""
|
||||
depth={1}
|
||||
current={current}
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onDropTo={onDropTo}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Children({
|
||||
root,
|
||||
path,
|
||||
depth,
|
||||
current,
|
||||
open,
|
||||
toggle,
|
||||
onSelect,
|
||||
onDropTo,
|
||||
pickOnly,
|
||||
}: {
|
||||
root: FileRoot;
|
||||
path: string;
|
||||
depth: number;
|
||||
current: Loc | null;
|
||||
open: Set<string>;
|
||||
toggle: (k: string) => void;
|
||||
onSelect: (loc: Loc) => void;
|
||||
onDropTo?: DropHandler;
|
||||
pickOnly: boolean;
|
||||
}) {
|
||||
const q = useDirChildren(root.id, path, true);
|
||||
if (q.isPending)
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 py-1 text-meta text-muted-foreground"
|
||||
style={{ paddingLeft: depth * 14 + 28 }}
|
||||
>
|
||||
<LoaderCircle className="h-3 w-3 animate-spin" /> 加载中
|
||||
</div>
|
||||
);
|
||||
if (q.error)
|
||||
return (
|
||||
<p className="py-1 text-meta text-destructive" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
读取失败
|
||||
</p>
|
||||
);
|
||||
const dirs = q.data?.dirs ?? [];
|
||||
if (!dirs.length)
|
||||
return (
|
||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
无子目录
|
||||
</p>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{dirs.map((d) => {
|
||||
const p = d.path || joinRel(path, d.name);
|
||||
const k = nodeKey(root.id, p);
|
||||
const isOpen = open.has(k);
|
||||
const active = current?.root === root.id && current.path === p;
|
||||
const disabled = pickOnly && !root.writable;
|
||||
return (
|
||||
<div key={k}>
|
||||
<TreeRow
|
||||
depth={depth}
|
||||
active={active}
|
||||
disabled={disabled}
|
||||
expandable
|
||||
expanded={isOpen}
|
||||
onToggle={() => toggle(k)}
|
||||
onClick={() => !disabled && onSelect({ root: root.id, path: p })}
|
||||
onDrop={onDropTo && root.writable ? (e) => onDropTo({ root: root.id, path: p }, e) : undefined}
|
||||
title={d.name}
|
||||
>
|
||||
{isOpen ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 flex-1 truncate text-body">{d.name}</span>
|
||||
</TreeRow>
|
||||
{isOpen ? (
|
||||
<Children
|
||||
root={root}
|
||||
path={p}
|
||||
depth={depth + 1}
|
||||
current={current}
|
||||
open={open}
|
||||
toggle={toggle}
|
||||
onSelect={onSelect}
|
||||
onDropTo={onDropTo}
|
||||
pickOnly={pickOnly}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{q.data?.truncated ? (
|
||||
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
|
||||
只显示前 1000 个子目录
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TreeRow({
|
||||
depth,
|
||||
active,
|
||||
disabled,
|
||||
expandable,
|
||||
expanded,
|
||||
onToggle,
|
||||
onClick,
|
||||
onDrop,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
depth: number;
|
||||
active: boolean;
|
||||
disabled?: boolean;
|
||||
expandable: boolean;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onClick: () => void;
|
||||
onDrop?: (e: React.DragEvent) => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [over, setOver] = useState(false);
|
||||
return (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: 整行是拖放目标,交互本体是里面的两个按钮
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-1 rounded-lg pr-2 transition-colors duration-micro",
|
||||
active
|
||||
? "pill-selected text-foreground"
|
||||
: "text-muted-foreground hover:bg-foreground/[0.04] hover:text-foreground",
|
||||
disabled && "opacity-50",
|
||||
over && "bg-foreground/[0.08] ring-1 ring-ring",
|
||||
)}
|
||||
style={{ paddingLeft: depth * 14 }}
|
||||
onDragOver={
|
||||
onDrop
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = e.altKey ? "copy" : "move";
|
||||
setOver(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onDragLeave={onDrop ? () => setOver(false) : undefined}
|
||||
onDrop={
|
||||
onDrop
|
||||
? (e) => {
|
||||
e.preventDefault();
|
||||
setOver(false);
|
||||
onDrop(e);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label={expanded ? "收起" : "展开"}
|
||||
aria-expanded={expanded}
|
||||
onClick={onToggle}
|
||||
className={cn("grid h-7 w-6 shrink-0 place-items-center rounded-md", !expandable && "invisible")}
|
||||
>
|
||||
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform duration-base", expanded && "rotate-90")} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
aria-current={active ? "location" : undefined}
|
||||
aria-disabled={disabled || undefined}
|
||||
className={cn(
|
||||
"flex min-w-0 flex-1 items-center gap-2 py-1.5 text-left outline-none focus-visible:underline",
|
||||
disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user