- 设置 → 媒体库与目录 → 存储:登记 Alist / OpenList(令牌 / 账号密码 / 访客、路径密码、限速与监控选项), 测试连接显示版本、用户与容量;媒体库目录可选存储 - 文件管理左栏多一组「Alist 存储」,路径显示「存储名:/路径」;移动 / 复制目标可选 Alist 存储; 上传按钮;Alist 根点刷新才让 Alist 刷新目录缓存 - 运行状态的挂载根列出存储实例(令牌失效单独提示) Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
385 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|