321 lines
9.3 KiB
TypeScript
321 lines
9.3 KiB
TypeScript
"use client";
|
|||
|
|
|
||
|
|
import { ChevronRight, FileText, Folder, Paperclip, Plus } from "lucide-react";
|
||
|
|
import type { DocumentNode } from "@/lib/documents";
|
||
|
|
import type { MediaAsset } from "@/types/media";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
import { useMemo } from "react";
|
||
|
|
|
||
|
|
interface FileTreeProps {
|
||
|
|
nodes: DocumentNode[];
|
||
|
|
assetsByDoc: Record<string, MediaAsset[]>;
|
||
|
|
expanded: Set<string>;
|
||
|
|
activeId: string;
|
||
|
|
onToggleExpand: (id: string) => void;
|
||
|
|
onOpenDocument: (id: string) => void;
|
||
|
|
onOpenAsset: (asset: MediaAsset) => void;
|
||
|
|
onCreateChild: (parentId: string | null) => void;
|
||
|
|
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||
|
|
selectedAssetIds?: Set<string>;
|
||
|
|
onToggleAssetSelect?: (assetId: string) => void;
|
||
|
|
onSelectOnlyAsset?: (assetId: string) => void;
|
||
|
|
disableSelection?: boolean;
|
||
|
|
onDropFiles?: (docId: string, files: FileList) => void;
|
||
|
|
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
const INDENT = 16;
|
||
|
|
|
||
|
|
export function FileTree({
|
||
|
|
nodes,
|
||
|
|
assetsByDoc,
|
||
|
|
expanded,
|
||
|
|
activeId,
|
||
|
|
onToggleExpand,
|
||
|
|
onOpenDocument,
|
||
|
|
onOpenAsset,
|
||
|
|
onCreateChild,
|
||
|
|
onContextMenu,
|
||
|
|
selectedAssetIds = new Set<string>(),
|
||
|
|
onToggleAssetSelect,
|
||
|
|
onSelectOnlyAsset,
|
||
|
|
disableSelection = false,
|
||
|
|
onDropFiles,
|
||
|
|
onAssetContextMenu,
|
||
|
|
}: FileTreeProps) {
|
||
|
|
if (nodes.length === 0) {
|
||
|
|
return <div className="px-4 py-6 text-sm text-gray-400">暂无页面,点击下方按钮创建</div>;
|
||
|
|
}
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="divide-y divide-[#f4f4f5]"
|
||
|
|
onDragOver={(e) => {
|
||
|
|
if (onDropFiles) {
|
||
|
|
e.preventDefault();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
onDrop={(e) => {
|
||
|
|
if (onDropFiles && e.dataTransfer.files?.length) {
|
||
|
|
e.preventDefault();
|
||
|
|
const files = e.dataTransfer.files;
|
||
|
|
const targetDoc = nodes[0]?.id ?? "";
|
||
|
|
onDropFiles(targetDoc, files);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{nodes.map((node) => (
|
||
|
|
<FileTreeNode
|
||
|
|
key={node.id}
|
||
|
|
node={node}
|
||
|
|
depth={0}
|
||
|
|
assetsByDoc={assetsByDoc}
|
||
|
|
expanded={expanded}
|
||
|
|
activeId={activeId}
|
||
|
|
onToggleExpand={onToggleExpand}
|
||
|
|
onOpenDocument={onOpenDocument}
|
||
|
|
onOpenAsset={onOpenAsset}
|
||
|
|
onCreateChild={onCreateChild}
|
||
|
|
onContextMenu={onContextMenu}
|
||
|
|
selectedAssetIds={selectedAssetIds}
|
||
|
|
onToggleAssetSelect={onToggleAssetSelect}
|
||
|
|
onSelectOnlyAsset={onSelectOnlyAsset}
|
||
|
|
disableSelection={disableSelection}
|
||
|
|
onDropFiles={onDropFiles}
|
||
|
|
onAssetContextMenu={onAssetContextMenu}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
interface FileTreeNodeProps {
|
||
|
|
node: DocumentNode;
|
||
|
|
depth: number;
|
||
|
|
assetsByDoc: Record<string, MediaAsset[]>;
|
||
|
|
expanded: Set<string>;
|
||
|
|
activeId: string;
|
||
|
|
onToggleExpand: (id: string) => void;
|
||
|
|
onOpenDocument: (id: string) => void;
|
||
|
|
onOpenAsset: (asset: MediaAsset) => void;
|
||
|
|
onCreateChild: (parentId: string | null) => void;
|
||
|
|
onContextMenu: (event: React.MouseEvent, node: DocumentNode) => void;
|
||
|
|
selectedAssetIds: Set<string>;
|
||
|
|
onToggleAssetSelect?: (assetId: string) => void;
|
||
|
|
onSelectOnlyAsset?: (assetId: string) => void;
|
||
|
|
disableSelection: boolean;
|
||
|
|
onDropFiles?: (docId: string, files: FileList) => void;
|
||
|
|
onAssetContextMenu?: (event: React.MouseEvent, asset: MediaAsset) => void;
|
||
|
|
}
|
||
|
|
|
||
|
|
function FileTreeNode({
|
||
|
|
node,
|
||
|
|
depth,
|
||
|
|
assetsByDoc,
|
||
|
|
expanded,
|
||
|
|
activeId,
|
||
|
|
onToggleExpand,
|
||
|
|
onOpenDocument,
|
||
|
|
onOpenAsset,
|
||
|
|
onCreateChild,
|
||
|
|
onContextMenu,
|
||
|
|
selectedAssetIds,
|
||
|
|
onToggleAssetSelect,
|
||
|
|
onSelectOnlyAsset,
|
||
|
|
disableSelection,
|
||
|
|
onDropFiles,
|
||
|
|
onAssetContextMenu,
|
||
|
|
}: FileTreeNodeProps) {
|
||
|
|
const isExpanded = expanded.has(node.id);
|
||
|
|
const assets = useMemo(() => assetsByDoc[node.id] ?? [], [assetsByDoc, node.id]);
|
||
|
|
const hasChildren = node.children.length > 0 || assets.length > 0;
|
||
|
|
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="py-0.5"
|
||
|
|
onDragOver={(e) => {
|
||
|
|
if (onDropFiles) e.preventDefault();
|
||
|
|
}}
|
||
|
|
onDrop={(e) => {
|
||
|
|
if (onDropFiles && e.dataTransfer.files?.length) {
|
||
|
|
e.preventDefault();
|
||
|
|
onDropFiles(node.id, e.dataTransfer.files);
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-gray-700 hover:bg-[#f5f7fb]",
|
||
|
|
activeId === node.id && "bg-[#e8f2ff] text-[#2563eb]",
|
||
|
|
)}
|
||
|
|
style={{ paddingLeft: depth * INDENT + 8 }}
|
||
|
|
onContextMenu={(event) => onContextMenu(event, node)}
|
||
|
|
>
|
||
|
|
{hasChildren ? (
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
aria-label="展开或折叠"
|
||
|
|
onClick={() => onToggleExpand(node.id)}
|
||
|
|
className="flex h-5 w-5 items-center justify-center rounded-md hover:bg-gray-100"
|
||
|
|
>
|
||
|
|
<ChevronRight className={cn("h-3.5 w-3.5 transition-transform", isExpanded && "rotate-90")} />
|
||
|
|
</button>
|
||
|
|
) : (
|
||
|
|
<span className="h-5 w-5" />
|
||
|
|
)}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex flex-1 items-center gap-2 text-left"
|
||
|
|
onClick={() => onOpenDocument(node.id)}
|
||
|
|
>
|
||
|
|
<Folder className="h-4 w-4 text-[#2563eb]" />
|
||
|
|
<span className="truncate">{node.title || "无标题"}</span>
|
||
|
|
</button>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
aria-label="新建子页面"
|
||
|
|
onClick={() => onCreateChild(node.id)}
|
||
|
|
className="rounded-md p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-700"
|
||
|
|
>
|
||
|
|
<Plus className="h-3.5 w-3.5" />
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
{isExpanded && (
|
||
|
|
<div className="space-y-0.5">
|
||
|
|
<FileLeafRow
|
||
|
|
depth={depth + 1}
|
||
|
|
label="index.md"
|
||
|
|
icon={<FileText className="h-4 w-4 text-gray-500" />}
|
||
|
|
onClick={() => onOpenDocument(node.id)}
|
||
|
|
stopBubble
|
||
|
|
/>
|
||
|
|
{assets.map((asset) => (
|
||
|
|
<FileLeafRow
|
||
|
|
key={asset.id}
|
||
|
|
depth={depth + 1}
|
||
|
|
label={asset.file_name || "附件"}
|
||
|
|
icon={<Paperclip className="h-4 w-4 text-gray-500" />}
|
||
|
|
selected={selectedAssetIds.has(asset.id)}
|
||
|
|
selectable={!disableSelection}
|
||
|
|
onSelectToggle={
|
||
|
|
onToggleAssetSelect ? () => onToggleAssetSelect(asset.id) : undefined
|
||
|
|
}
|
||
|
|
onSelectOnly={onSelectOnlyAsset ? () => onSelectOnlyAsset(asset.id) : undefined}
|
||
|
|
onClick={() => onOpenAsset(asset)}
|
||
|
|
stopBubble
|
||
|
|
onContextMenu={
|
||
|
|
onAssetContextMenu
|
||
|
|
? (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
onAssetContextMenu(e, asset);
|
||
|
|
}
|
||
|
|
: undefined
|
||
|
|
}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
{node.children.map((child) => (
|
||
|
|
<FileTreeNode
|
||
|
|
key={child.id}
|
||
|
|
node={child}
|
||
|
|
depth={depth + 1}
|
||
|
|
assetsByDoc={assetsByDoc}
|
||
|
|
expanded={expanded}
|
||
|
|
activeId={activeId}
|
||
|
|
onToggleExpand={onToggleExpand}
|
||
|
|
onOpenDocument={onOpenDocument}
|
||
|
|
onOpenAsset={onOpenAsset}
|
||
|
|
onCreateChild={onCreateChild}
|
||
|
|
onContextMenu={onContextMenu}
|
||
|
|
selectedAssetIds={selectedAssetIds}
|
||
|
|
onToggleAssetSelect={onToggleAssetSelect}
|
||
|
|
onSelectOnlyAsset={onSelectOnlyAsset}
|
||
|
|
disableSelection={disableSelection}
|
||
|
|
onDropFiles={onDropFiles}
|
||
|
|
onAssetContextMenu={onAssetContextMenu}
|
||
|
|
/>
|
||
|
|
))}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
function FileLeafRow({
|
||
|
|
depth,
|
||
|
|
label,
|
||
|
|
icon,
|
||
|
|
onClick,
|
||
|
|
selected = false,
|
||
|
|
selectable = false,
|
||
|
|
onSelectToggle,
|
||
|
|
onSelectOnly,
|
||
|
|
stopBubble = false,
|
||
|
|
onContextMenu,
|
||
|
|
}: {
|
||
|
|
depth: number;
|
||
|
|
label: string;
|
||
|
|
icon: React.ReactNode;
|
||
|
|
onClick: () => void;
|
||
|
|
selected?: boolean;
|
||
|
|
selectable?: boolean;
|
||
|
|
onSelectToggle?: () => void;
|
||
|
|
onSelectOnly?: () => void;
|
||
|
|
stopBubble?: boolean;
|
||
|
|
onContextMenu?: (event: React.MouseEvent) => void;
|
||
|
|
}) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className="flex items-center gap-2 rounded-md px-2 py-1 text-sm text-gray-600 hover:bg-[#f8fafc]"
|
||
|
|
style={{ paddingLeft: depth * INDENT + 32 }}
|
||
|
|
onClick={(e) => {
|
||
|
|
if (stopBubble) e.stopPropagation();
|
||
|
|
// 附件行默认只负责选中,不直接打开,避免误触下载
|
||
|
|
if (selectable) return;
|
||
|
|
onClick();
|
||
|
|
}}
|
||
|
|
onMouseDown={(e) => {
|
||
|
|
if (stopBubble) e.stopPropagation();
|
||
|
|
}}
|
||
|
|
role="button"
|
||
|
|
tabIndex={0}
|
||
|
|
onContextMenu={(e) => {
|
||
|
|
if (stopBubble) e.stopPropagation();
|
||
|
|
if (onContextMenu) onContextMenu(e);
|
||
|
|
}}
|
||
|
|
onKeyDown={(event) => {
|
||
|
|
if (event.key === "Enter" || event.key === " ") {
|
||
|
|
event.preventDefault();
|
||
|
|
onClick();
|
||
|
|
}
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{selectable ? (
|
||
|
|
<input
|
||
|
|
type="checkbox"
|
||
|
|
checked={selected}
|
||
|
|
onChange={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
if (onSelectToggle) onSelectToggle();
|
||
|
|
}}
|
||
|
|
className="h-4 w-4 rounded border-gray-300 text-[#2563eb]"
|
||
|
|
/>
|
||
|
|
) : (
|
||
|
|
<span className="h-4 w-4" />
|
||
|
|
)}
|
||
|
|
{icon}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
className="flex-1 truncate text-left"
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
onClick();
|
||
|
|
}}
|
||
|
|
>
|
||
|
|
{label}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|