Files
UHD_Media_Tool/apps/web/app/components/files/dir-tree.tsx
T
rootandClaude Opus 5.5 32bf49a148 文案(文件管理):按 human-copy 重写对话框、状态、菜单与提示
- 永久删除的说明改成实际开关 FILES_ALLOW_PERMANENT_DELETE,去掉「在设置里开启」的错误说法
- 去掉「待处理」「仅 TMDB」「目录根」等旧称,收录版本退回状态写「需处理」
- 删除、移动/复制、重命名、刮削、刷新元数据对话框去掉复述界面的说明
- 加载与进行中文字统一带省略号;Toast 只写结果

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
2026-10-07 01:48:20 +08:00

385 lines
12 KiB
TypeScript

import {
ChevronRight,
Cloud,
Download,
Folder,
FolderOpen,
HardDrive,
LoaderCircle,
TriangleAlert,
} from "lucide-react";
import { useEffect, useState } from "react";
import { type FileRoot, useDirChildren } from "~/api/files.ts";
import { useListPagination } from "~/components/console/use-list-pagination.ts";
import { Pagination } from "~/components/ui/pagination.tsx";
import { healthLabel } from "~/lib/labels.ts";
import { cn } from "~/lib/utils.ts";
import { displayPath, groupRoots, 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,
paginate = false,
defaultOpen = [],
className,
}: {
roots: FileRoot[];
current: Loc | null;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly?: boolean;
/** 目录选择小窗按页展示子目录;主文件树保持懒展开。 */
paginate?: 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 = groupRoots(roots);
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/75 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}
paginate={paginate}
/>
))}
</div>
</section>
))}
</nav>
);
}
function RootNode({
root,
current,
open,
toggle,
onSelect,
onDropTo,
pickOnly,
paginate,
}: {
root: FileRoot;
current: Loc | null;
open: Set<string>;
toggle: (k: string) => void;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
paginate: 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 : root.kind === "storage" ? Cloud : 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 ? "目录离线" : "只读目录,不能作为目标") : displayPath(root)}
>
<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>
) : null}
{root.pendingCount ? (
<span className="shrink-0 text-warning">
未识别 {root.pendingCount}
{root.pendingPartial ? "+" : ""}
</span>
) : null}
{!root.writable && root.kind === "library" ? <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}
paginate={paginate}
/>
) : null}
</div>
);
}
function Children({
root,
path,
depth,
current,
open,
toggle,
onSelect,
onDropTo,
pickOnly,
paginate,
}: {
root: FileRoot;
path: string;
depth: number;
current: Loc | null;
open: Set<string>;
toggle: (k: string) => void;
onSelect: (loc: Loc) => void;
onDropTo?: DropHandler;
pickOnly: boolean;
paginate: boolean;
}) {
const q = useDirChildren(root.id, path, true);
const dirs = q.data?.dirs ?? [];
const paged = useListPagination(dirs, { resetKey: JSON.stringify({ root: root.id, path }) });
const selectedIndex =
current?.root === root.id
? dirs.findIndex((dir) => {
const childPath = dir.path || joinRel(path, dir.name);
return current.path === childPath || current.path.startsWith(`${childPath}/`);
})
: -1;
// biome-ignore lint/correctness/useExhaustiveDependencies: 选中目录改变时定位;手动翻页不改变已有选择
useEffect(() => {
if (paginate && selectedIndex >= 0)
paged.pagination.onPageChange(Math.floor(selectedIndex / paged.pagination.pageSize) + 1);
}, [paginate, root.id, path, selectedIndex, paged.pagination.pageSize]);
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>
);
if (!dirs.length)
return (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
没有子目录
</p>
);
return (
<div>
{(paginate ? paged.items : 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={d.hasChildren}
expanded={isOpen && d.hasChildren}
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 && d.hasChildren ? (
<Children
root={root}
path={p}
depth={depth + 1}
current={current}
open={open}
toggle={toggle}
onSelect={onSelect}
onDropTo={onDropTo}
pickOnly={pickOnly}
paginate={paginate}
/>
) : null}
</div>
);
})}
{paginate && dirs.length > 0 ? <Pagination {...paged.pagination} /> : null}
{q.data?.truncated ? (
<p className="py-1 text-meta text-muted-foreground" style={{ paddingLeft: depth * 14 + 28 }}>
子目录太多,只列出一部分
</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>
);
}