Files
UHD_Media_Tool/apps/web/app/components/files/dir-tree.tsx
T
rootandClaude Opus 5.5 89ea68465e 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]>
2026-09-27 23:16:35 +08:00

353 lines
10 KiB
TypeScript

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>
);
}