720 lines
19 KiB
TypeScript
720 lines
19 KiB
TypeScript
|
|
'use client';
|
|||
|
|
|
|||
|
|
import * as React from 'react';
|
|||
|
|
import { useState } from 'react';
|
|||
|
|
import { Button } from '@nice/ui/components/button';
|
|||
|
|
import { Badge } from '@nice/ui/components/badge';
|
|||
|
|
import {
|
|||
|
|
DndContext,
|
|||
|
|
DragEndEvent,
|
|||
|
|
DragOverlay,
|
|||
|
|
DragStartEvent,
|
|||
|
|
PointerSensor,
|
|||
|
|
useSensor,
|
|||
|
|
useSensors,
|
|||
|
|
closestCenter,
|
|||
|
|
DragOverEvent,
|
|||
|
|
} from '@dnd-kit/core';
|
|||
|
|
import {
|
|||
|
|
SortableContext,
|
|||
|
|
verticalListSortingStrategy,
|
|||
|
|
useSortable,
|
|||
|
|
} from '@dnd-kit/sortable';
|
|||
|
|
import { CSS } from '@dnd-kit/utilities';
|
|||
|
|
|
|||
|
|
import {
|
|||
|
|
IconChevronRight,
|
|||
|
|
IconChevronDown,
|
|||
|
|
IconDots,
|
|||
|
|
IconEdit,
|
|||
|
|
IconTrash,
|
|||
|
|
IconPlus,
|
|||
|
|
IconGripVertical,
|
|||
|
|
} from '@tabler/icons-react';
|
|||
|
|
import {
|
|||
|
|
DropdownMenu,
|
|||
|
|
DropdownMenuContent,
|
|||
|
|
DropdownMenuItem,
|
|||
|
|
DropdownMenuSeparator,
|
|||
|
|
DropdownMenuTrigger,
|
|||
|
|
} from '@nice/ui/components/dropdown-menu';
|
|||
|
|
import { TaxonomySlug, type TermWithTaxonomy } from '@fenghuo/common';
|
|||
|
|
|
|||
|
|
// 通用树节点接口
|
|||
|
|
export interface TreeNode {
|
|||
|
|
id: string;
|
|||
|
|
name: string;
|
|||
|
|
slug?: string | null;
|
|||
|
|
description?: string | null;
|
|||
|
|
parentId?: string | null;
|
|||
|
|
level?: number;
|
|||
|
|
order?: number; // 添加排序字段
|
|||
|
|
children?: TreeNode[];
|
|||
|
|
terms?: TermWithTaxonomy[]; // 添加 terms 字段支持
|
|||
|
|
[key: string]: any; // 允许额外的属性
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 图标配置
|
|||
|
|
export interface TreeIconConfig {
|
|||
|
|
// 根节点图标
|
|||
|
|
rootIcon?: React.ComponentType<{ className?: string }>;
|
|||
|
|
// 父节点图标(有子节点)
|
|||
|
|
parentIcon?: React.ComponentType<{ className?: string }>;
|
|||
|
|
// 父节点展开时图标
|
|||
|
|
parentExpandedIcon?: React.ComponentType<{ className?: string }>;
|
|||
|
|
// 叶子节点图标
|
|||
|
|
leafIcon?: React.ComponentType<{ className?: string }>;
|
|||
|
|
// 根据节点动态决定图标的函数
|
|||
|
|
getIcon?: (node: TreeNode, hasChildren: boolean, isExpanded: boolean) => React.ComponentType<{ className?: string }>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 操作配置
|
|||
|
|
export interface TreeActionConfig<T extends TreeNode> {
|
|||
|
|
// 编辑操作
|
|||
|
|
onEdit?: (node: T) => void;
|
|||
|
|
editLabel?: string;
|
|||
|
|
// 删除操作
|
|||
|
|
onDelete?: (node: T) => void;
|
|||
|
|
deleteLabel?: string;
|
|||
|
|
// 添加子节点操作
|
|||
|
|
onAddChild?: (parentId: string) => void;
|
|||
|
|
addChildLabel?: string;
|
|||
|
|
// 自定义操作
|
|||
|
|
customActions?: Array<{
|
|||
|
|
label: string;
|
|||
|
|
icon?: React.ComponentType<{ className?: string }>;
|
|||
|
|
onClick: (node: T) => void;
|
|||
|
|
className?: string;
|
|||
|
|
}>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 显示配置
|
|||
|
|
export interface TreeDisplayConfig<T extends TreeNode> {
|
|||
|
|
// 是否显示描述
|
|||
|
|
showDescription?: boolean;
|
|||
|
|
// 是否显示 slug
|
|||
|
|
showSlug?: boolean;
|
|||
|
|
// 自定义渲染节点内容
|
|||
|
|
renderContent?: (node: T) => React.ReactNode;
|
|||
|
|
// 自定义渲染额外信息(如徽章)
|
|||
|
|
renderExtra?: (node: T) => React.ReactNode;
|
|||
|
|
// 节点选择样式配置
|
|||
|
|
selectedClassName?: string;
|
|||
|
|
hoverClassName?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 拖拽配置
|
|||
|
|
export interface TreeDragConfig<T extends TreeNode> {
|
|||
|
|
// 是否启用拖拽
|
|||
|
|
enabled?: boolean;
|
|||
|
|
// 拖拽开始回调
|
|||
|
|
onDragStart?: (event: DragStartEvent) => void;
|
|||
|
|
// 拖拽结束回调 - 返回更新后的数据
|
|||
|
|
onDragEnd?: (oldData: T[], newData: T[]) => void;
|
|||
|
|
// 拖拽过程中回调
|
|||
|
|
onDragOver?: (event: DragOverEvent) => void;
|
|||
|
|
// 是否显示拖拽手柄
|
|||
|
|
showHandle?: boolean;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 可拖拽的树节点组件属性
|
|||
|
|
interface DraggableTreeNodeProps<T extends TreeNode> {
|
|||
|
|
node: T;
|
|||
|
|
level: number;
|
|||
|
|
expandedIds: Set<string>;
|
|||
|
|
selectedNode?: T | null;
|
|||
|
|
onToggleExpand: (id: string) => void;
|
|||
|
|
onSelect?: (node: T) => void;
|
|||
|
|
iconConfig: TreeIconConfig;
|
|||
|
|
actionConfig: TreeActionConfig<T>;
|
|||
|
|
displayConfig: TreeDisplayConfig<T>;
|
|||
|
|
dragConfig: TreeDragConfig<T>;
|
|||
|
|
indentSize?: number;
|
|||
|
|
isDragging?: boolean;
|
|||
|
|
siblings: T[];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 可拖拽的树节点组件
|
|||
|
|
function DraggableTreeNode<T extends TreeNode>({
|
|||
|
|
node,
|
|||
|
|
level,
|
|||
|
|
expandedIds,
|
|||
|
|
selectedNode,
|
|||
|
|
onToggleExpand,
|
|||
|
|
onSelect,
|
|||
|
|
iconConfig,
|
|||
|
|
actionConfig,
|
|||
|
|
displayConfig,
|
|||
|
|
dragConfig,
|
|||
|
|
indentSize = 20,
|
|||
|
|
isDragging = false,
|
|||
|
|
siblings,
|
|||
|
|
}: DraggableTreeNodeProps<T>) {
|
|||
|
|
// 只有在启用拖拽时才使用 useSortable
|
|||
|
|
const sortableProps = dragConfig.enabled ? useSortable({ id: node.id }) : null;
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
attributes = {},
|
|||
|
|
listeners = {},
|
|||
|
|
setNodeRef = () => { },
|
|||
|
|
transform = null,
|
|||
|
|
transition = '',
|
|||
|
|
isDragging: isSortableDragging = false,
|
|||
|
|
} = sortableProps || {};
|
|||
|
|
|
|||
|
|
const style = dragConfig.enabled ? {
|
|||
|
|
transform: CSS.Transform.toString(transform),
|
|||
|
|
transition,
|
|||
|
|
} : {};
|
|||
|
|
|
|||
|
|
const hasChildren = node.children && node.children.length > 0;
|
|||
|
|
const isExpanded = expandedIds.has(node.id);
|
|||
|
|
const isSelected = selectedNode?.id === node.id;
|
|||
|
|
|
|||
|
|
// 获取图标
|
|||
|
|
const getNodeIcon = () => {
|
|||
|
|
if (iconConfig.getIcon) {
|
|||
|
|
return iconConfig.getIcon(node, !!hasChildren, isExpanded);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (level === 0 && iconConfig.rootIcon) {
|
|||
|
|
return iconConfig.rootIcon;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (hasChildren) {
|
|||
|
|
if (isExpanded && iconConfig.parentExpandedIcon) {
|
|||
|
|
return iconConfig.parentExpandedIcon;
|
|||
|
|
}
|
|||
|
|
return iconConfig.parentIcon;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return iconConfig.leafIcon;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const NodeIcon = getNodeIcon();
|
|||
|
|
|
|||
|
|
// 默认选中样式
|
|||
|
|
const defaultSelectedClassName = 'bg-primary/10 border border-primary/20';
|
|||
|
|
const defaultHoverClassName = 'hover:bg-muted/50 border border-transparent';
|
|||
|
|
|
|||
|
|
const selectedClassName = displayConfig.selectedClassName || defaultSelectedClassName;
|
|||
|
|
const hoverClassName = displayConfig.hoverClassName || defaultHoverClassName;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="select-none" style={style} ref={setNodeRef}>
|
|||
|
|
<div
|
|||
|
|
className={`flex items-center justify-between p-2 rounded-md group cursor-pointer transition-colors ${isSelected ? selectedClassName : hoverClassName
|
|||
|
|
} ${isSortableDragging ? 'opacity-50' : ''} ${isDragging ? 'z-50' : ''}`}
|
|||
|
|
onClick={() => onSelect?.(node)}
|
|||
|
|
style={{ paddingLeft: `${level * indentSize + 8}px` }}
|
|||
|
|
>
|
|||
|
|
<div className="flex items-center gap-2 flex-1 min-w-0">
|
|||
|
|
{/* 拖拽手柄 */}
|
|||
|
|
{dragConfig.enabled && dragConfig.showHandle && (
|
|||
|
|
<div
|
|||
|
|
className="w-4 h-4 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-grab active:cursor-grabbing"
|
|||
|
|
{...attributes}
|
|||
|
|
{...listeners}
|
|||
|
|
>
|
|||
|
|
<IconGripVertical className="h-3 w-3 text-muted-foreground" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 展开/折叠按钮 */}
|
|||
|
|
<div className="w-5 h-5 flex items-center justify-center">
|
|||
|
|
{hasChildren ? (
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="sm"
|
|||
|
|
className="h-4 w-4 p-0 hover:bg-background"
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
onToggleExpand(node.id);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{isExpanded ? (
|
|||
|
|
<IconChevronDown className="h-3 w-3" />
|
|||
|
|
) : (
|
|||
|
|
<IconChevronRight className="h-3 w-3" />
|
|||
|
|
)}
|
|||
|
|
</Button>
|
|||
|
|
) : (
|
|||
|
|
<div className="w-3 h-3" />
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 图标 */}
|
|||
|
|
{NodeIcon && (
|
|||
|
|
<div className="w-5 h-5 flex items-center justify-center flex-shrink-0">
|
|||
|
|
<NodeIcon className="h-4 w-4" />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
|
|||
|
|
{/* 节点内容 */}
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
{displayConfig.renderContent ? (
|
|||
|
|
displayConfig.renderContent(node)
|
|||
|
|
) : (
|
|||
|
|
<>
|
|||
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|||
|
|
<span className="font-medium text-sm truncate">{node.name}</span>
|
|||
|
|
{/* 显示 organization_type 类型的 term 作为标签 */}
|
|||
|
|
{node.terms?.filter(term => term.taxonomy.slug === TaxonomySlug.ORGANIZATION_TYPE).map(term => (
|
|||
|
|
<Badge key={term.id} variant="secondary" className="text-xs shrink-0">
|
|||
|
|
{term.name}
|
|||
|
|
</Badge>
|
|||
|
|
))}
|
|||
|
|
{displayConfig.renderExtra && displayConfig.renderExtra(node)}
|
|||
|
|
</div>
|
|||
|
|
{displayConfig.showDescription && node.description && (
|
|||
|
|
<div className="text-xs text-muted-foreground mt-1 truncate">
|
|||
|
|
{node.description}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{/* 显示 profession 类型的 term 作为文本 */}
|
|||
|
|
{(() => {
|
|||
|
|
const professionTerms = node.terms?.filter(term => term.taxonomy.slug === TaxonomySlug.PROFESSION) || [];
|
|||
|
|
return professionTerms.length > 0 && (
|
|||
|
|
<div className="mt-1 flex flex-wrap gap-1">
|
|||
|
|
{professionTerms.map((term, index) => (
|
|||
|
|
<span key={term.id} className="text-xs text-muted-foreground">
|
|||
|
|
{index > 0 && '• '}{term.name}
|
|||
|
|
</span>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
})()}
|
|||
|
|
{displayConfig.showSlug && node.slug && (
|
|||
|
|
<div className="text-xs text-gray-400 dark:text-gray-500 mt-1">/{node.slug}</div>
|
|||
|
|
)}
|
|||
|
|
</>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 操作菜单 */}
|
|||
|
|
{(actionConfig.onEdit || actionConfig.onDelete || actionConfig.onAddChild || actionConfig.customActions) && (
|
|||
|
|
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
|||
|
|
<DropdownMenu>
|
|||
|
|
<DropdownMenuTrigger asChild>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="sm"
|
|||
|
|
className="h-6 w-6 p-0"
|
|||
|
|
onClick={(e) => e.stopPropagation()}
|
|||
|
|
>
|
|||
|
|
<IconDots className="h-4 w-4" />
|
|||
|
|
</Button>
|
|||
|
|
</DropdownMenuTrigger>
|
|||
|
|
<DropdownMenuContent align="end">
|
|||
|
|
{actionConfig.onEdit && (
|
|||
|
|
<DropdownMenuItem
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
actionConfig.onEdit!(node);
|
|||
|
|
}}
|
|||
|
|
className="cursor-pointer"
|
|||
|
|
>
|
|||
|
|
<IconEdit className="mr-2 h-4 w-4" />
|
|||
|
|
{actionConfig.editLabel || '编辑'}
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
)}
|
|||
|
|
{actionConfig.onAddChild && (
|
|||
|
|
<DropdownMenuItem
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
actionConfig.onAddChild!(node.id);
|
|||
|
|
}}
|
|||
|
|
className="cursor-pointer"
|
|||
|
|
>
|
|||
|
|
<IconPlus className="mr-2 h-4 w-4" />
|
|||
|
|
{actionConfig.addChildLabel || '添加子项'}
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
)}
|
|||
|
|
{actionConfig.customActions?.map((action, index) => (
|
|||
|
|
<DropdownMenuItem
|
|||
|
|
key={index}
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
action.onClick(node);
|
|||
|
|
}}
|
|||
|
|
className={`cursor-pointer ${action.className || ''}`}
|
|||
|
|
>
|
|||
|
|
{action.icon && <action.icon className="mr-2 h-4 w-4" />}
|
|||
|
|
{action.label}
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
))}
|
|||
|
|
{(actionConfig.onEdit || actionConfig.onAddChild || actionConfig.customActions) && actionConfig.onDelete && (
|
|||
|
|
<DropdownMenuSeparator />
|
|||
|
|
)}
|
|||
|
|
{actionConfig.onDelete && (
|
|||
|
|
<DropdownMenuItem
|
|||
|
|
onClick={(e) => {
|
|||
|
|
e.stopPropagation();
|
|||
|
|
actionConfig.onDelete!(node);
|
|||
|
|
}}
|
|||
|
|
className="text-destructive focus:text-destructive cursor-pointer"
|
|||
|
|
>
|
|||
|
|
<IconTrash className="mr-2 h-4 w-4" />
|
|||
|
|
{actionConfig.deleteLabel || '删除'}
|
|||
|
|
</DropdownMenuItem>
|
|||
|
|
)}
|
|||
|
|
</DropdownMenuContent>
|
|||
|
|
</DropdownMenu>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{/* 子节点 */}
|
|||
|
|
{hasChildren && isExpanded && (
|
|||
|
|
<div className="ml-1">
|
|||
|
|
<DraggableTreeLevel
|
|||
|
|
nodes={node.children! as T[]}
|
|||
|
|
level={level + 1}
|
|||
|
|
expandedIds={expandedIds}
|
|||
|
|
selectedNode={selectedNode}
|
|||
|
|
onToggleExpand={onToggleExpand}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
iconConfig={iconConfig}
|
|||
|
|
actionConfig={actionConfig}
|
|||
|
|
displayConfig={displayConfig}
|
|||
|
|
dragConfig={dragConfig}
|
|||
|
|
indentSize={indentSize}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 可拖拽的树级别组件
|
|||
|
|
function DraggableTreeLevel<T extends TreeNode>({
|
|||
|
|
nodes,
|
|||
|
|
level,
|
|||
|
|
expandedIds,
|
|||
|
|
selectedNode,
|
|||
|
|
onToggleExpand,
|
|||
|
|
onSelect,
|
|||
|
|
iconConfig,
|
|||
|
|
actionConfig,
|
|||
|
|
displayConfig,
|
|||
|
|
dragConfig,
|
|||
|
|
indentSize,
|
|||
|
|
}: {
|
|||
|
|
nodes: T[];
|
|||
|
|
level: number;
|
|||
|
|
expandedIds: Set<string>;
|
|||
|
|
selectedNode?: T | null;
|
|||
|
|
onToggleExpand: (id: string) => void;
|
|||
|
|
onSelect?: (node: T) => void;
|
|||
|
|
iconConfig: TreeIconConfig;
|
|||
|
|
actionConfig: TreeActionConfig<T>;
|
|||
|
|
displayConfig: TreeDisplayConfig<T>;
|
|||
|
|
dragConfig: TreeDragConfig<T>;
|
|||
|
|
indentSize?: number;
|
|||
|
|
}) {
|
|||
|
|
// 按order排序
|
|||
|
|
const sortedNodes = [...nodes].sort((a, b) => (a.order || 0) - (b.order || 0));
|
|||
|
|
|
|||
|
|
if (!dragConfig.enabled) {
|
|||
|
|
// 如果不启用拖拽,直接使用拖拽组件但不包装在SortableContext中
|
|||
|
|
return (
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{sortedNodes.map((node) => (
|
|||
|
|
<DraggableTreeNode
|
|||
|
|
key={node.id}
|
|||
|
|
node={node}
|
|||
|
|
level={level}
|
|||
|
|
expandedIds={expandedIds}
|
|||
|
|
selectedNode={selectedNode}
|
|||
|
|
onToggleExpand={onToggleExpand}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
iconConfig={iconConfig}
|
|||
|
|
actionConfig={actionConfig}
|
|||
|
|
displayConfig={displayConfig}
|
|||
|
|
dragConfig={dragConfig}
|
|||
|
|
indentSize={indentSize}
|
|||
|
|
siblings={sortedNodes}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<SortableContext items={sortedNodes.map(node => node.id)} strategy={verticalListSortingStrategy}>
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
{sortedNodes.map((node) => (
|
|||
|
|
<DraggableTreeNode
|
|||
|
|
key={node.id}
|
|||
|
|
node={node}
|
|||
|
|
level={level}
|
|||
|
|
expandedIds={expandedIds}
|
|||
|
|
selectedNode={selectedNode}
|
|||
|
|
onToggleExpand={onToggleExpand}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
iconConfig={iconConfig}
|
|||
|
|
actionConfig={actionConfig}
|
|||
|
|
displayConfig={displayConfig}
|
|||
|
|
dragConfig={dragConfig}
|
|||
|
|
indentSize={indentSize}
|
|||
|
|
siblings={sortedNodes}
|
|||
|
|
/>
|
|||
|
|
))}
|
|||
|
|
</div>
|
|||
|
|
</SortableContext>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// 树选择器组件属性
|
|||
|
|
export interface TreeSelectorProps<T extends TreeNode> {
|
|||
|
|
// 数据
|
|||
|
|
data: T[];
|
|||
|
|
// 选中的节点
|
|||
|
|
selectedNode?: T | null;
|
|||
|
|
// 选择回调
|
|||
|
|
onSelect?: (node: T) => void;
|
|||
|
|
// 图标配置
|
|||
|
|
iconConfig?: TreeIconConfig;
|
|||
|
|
// 操作配置
|
|||
|
|
actionConfig?: TreeActionConfig<T>;
|
|||
|
|
// 显示配置
|
|||
|
|
displayConfig?: TreeDisplayConfig<T>;
|
|||
|
|
// 拖拽配置
|
|||
|
|
dragConfig?: TreeDragConfig<T>;
|
|||
|
|
// 缩进大小
|
|||
|
|
indentSize?: number;
|
|||
|
|
// 容器样式
|
|||
|
|
className?: string;
|
|||
|
|
// 是否显示在容器中
|
|||
|
|
containerized?: boolean;
|
|||
|
|
// 最大高度
|
|||
|
|
maxHeight?: string;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 工具函数:查找节点的同级节点ID
|
|||
|
|
function findSiblingNodes<T extends TreeNode>(nodes: T[], targetId: string): string[] {
|
|||
|
|
const siblings: string[] = [];
|
|||
|
|
|
|||
|
|
function searchInLevel(levelNodes: T[]): boolean {
|
|||
|
|
// 检查当前层级是否包含目标节点
|
|||
|
|
const hasTarget = levelNodes.some(node => node.id === targetId);
|
|||
|
|
|
|||
|
|
if (hasTarget) {
|
|||
|
|
// 如果当前层级包含目标节点,返回所有同级节点的ID
|
|||
|
|
siblings.push(...levelNodes.map(node => node.id));
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 递归搜索子节点
|
|||
|
|
for (const node of levelNodes) {
|
|||
|
|
if (node.children && searchInLevel(node.children as T[])) {
|
|||
|
|
return true;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
searchInLevel(nodes);
|
|||
|
|
return siblings;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 支持 BigInt 的深拷贝函数
|
|||
|
|
function deepClone<T>(obj: T): T {
|
|||
|
|
if (obj === null || typeof obj !== 'object') {
|
|||
|
|
return obj;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (obj instanceof Date) {
|
|||
|
|
return new Date(obj.getTime()) as T;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (obj instanceof Array) {
|
|||
|
|
return obj.map(item => deepClone(item)) as T;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (typeof obj === 'object') {
|
|||
|
|
const cloned = {} as T;
|
|||
|
|
for (const key in obj) {
|
|||
|
|
if (obj.hasOwnProperty(key)) {
|
|||
|
|
cloned[key] = deepClone(obj[key]);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return cloned;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return obj;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 工具函数:更新节点的order值
|
|||
|
|
function updateNodeOrder<T extends TreeNode>(nodes: T[], draggedId: string, targetId: string): T[] {
|
|||
|
|
const clonedNodes = deepClone(nodes);
|
|||
|
|
|
|||
|
|
// 递归查找并更新节点
|
|||
|
|
function updateInLevel(levelNodes: T[]): T[] {
|
|||
|
|
// 找到拖拽的节点和目标节点
|
|||
|
|
const draggedIndex = levelNodes.findIndex(node => node.id === draggedId);
|
|||
|
|
const targetIndex = levelNodes.findIndex(node => node.id === targetId);
|
|||
|
|
|
|||
|
|
if (draggedIndex !== -1 && targetIndex !== -1) {
|
|||
|
|
// 在同一层级内重新排列
|
|||
|
|
const draggedNodes = levelNodes.splice(draggedIndex, 1);
|
|||
|
|
const draggedNode = draggedNodes[0];
|
|||
|
|
if (draggedNode) {
|
|||
|
|
levelNodes.splice(targetIndex, 0, draggedNode);
|
|||
|
|
|
|||
|
|
// 重新计算order值
|
|||
|
|
levelNodes.forEach((node, index) => {
|
|||
|
|
node.order = index;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return levelNodes;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 递归处理子节点
|
|||
|
|
return levelNodes.map(node => ({
|
|||
|
|
...node,
|
|||
|
|
children: node.children ? updateInLevel(node.children as T[]) : node.children
|
|||
|
|
}));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return updateInLevel(clonedNodes);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 主要的树选择器组件
|
|||
|
|
export function TreeSelector<T extends TreeNode>({
|
|||
|
|
data,
|
|||
|
|
selectedNode,
|
|||
|
|
onSelect,
|
|||
|
|
iconConfig = {},
|
|||
|
|
actionConfig = {},
|
|||
|
|
displayConfig = {},
|
|||
|
|
dragConfig = {},
|
|||
|
|
indentSize = 20,
|
|||
|
|
className = '',
|
|||
|
|
containerized = true,
|
|||
|
|
maxHeight = 'calc(100vh - 300px)',
|
|||
|
|
}: TreeSelectorProps<T>) {
|
|||
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|||
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|||
|
|
// 设置传感器
|
|||
|
|
const sensors = useSensors(
|
|||
|
|
useSensor(PointerSensor, {
|
|||
|
|
activationConstraint: {
|
|||
|
|
distance: 8,
|
|||
|
|
},
|
|||
|
|
})
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const toggleExpand = (id: string) => {
|
|||
|
|
const newExpanded = new Set(expandedIds);
|
|||
|
|
if (newExpanded.has(id)) {
|
|||
|
|
newExpanded.delete(id);
|
|||
|
|
} else {
|
|||
|
|
newExpanded.add(id);
|
|||
|
|
}
|
|||
|
|
setExpandedIds(newExpanded);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|||
|
|
const { active } = event;
|
|||
|
|
setActiveId(active.id as string);
|
|||
|
|
|
|||
|
|
// 找到同级节点并收起它们
|
|||
|
|
const siblingIds = findSiblingNodes(data, active.id as string);
|
|||
|
|
const newExpandedIds = new Set(expandedIds);
|
|||
|
|
|
|||
|
|
// 移除同级节点(包括拖拽节点本身)
|
|||
|
|
siblingIds.forEach(id => {
|
|||
|
|
newExpandedIds.delete(id);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
setExpandedIds(newExpandedIds);
|
|||
|
|
|
|||
|
|
if (dragConfig.onDragStart) {
|
|||
|
|
dragConfig.onDragStart(event);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDragEnd = (event: DragEndEvent) => {
|
|||
|
|
const { active, over } = event;
|
|||
|
|
|
|||
|
|
setActiveId(null);
|
|||
|
|
|
|||
|
|
if (!over || active.id === over.id) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 更新数据
|
|||
|
|
const newData = updateNodeOrder(data, active.id as string, over.id as string);
|
|||
|
|
|
|||
|
|
// 调用回调
|
|||
|
|
if (dragConfig.onDragEnd) {
|
|||
|
|
dragConfig.onDragEnd(data, newData);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDragOver = (event: DragOverEvent) => {
|
|||
|
|
if (dragConfig.onDragOver) {
|
|||
|
|
dragConfig.onDragOver(event);
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const content = (
|
|||
|
|
<div className="space-y-1">
|
|||
|
|
<DraggableTreeLevel
|
|||
|
|
nodes={data}
|
|||
|
|
level={0}
|
|||
|
|
expandedIds={expandedIds}
|
|||
|
|
selectedNode={selectedNode}
|
|||
|
|
onToggleExpand={toggleExpand}
|
|||
|
|
onSelect={onSelect}
|
|||
|
|
iconConfig={iconConfig}
|
|||
|
|
actionConfig={actionConfig}
|
|||
|
|
displayConfig={displayConfig}
|
|||
|
|
dragConfig={dragConfig}
|
|||
|
|
indentSize={indentSize}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
// 如果启用拖拽,使用DndContext包装
|
|||
|
|
const wrappedContent = dragConfig.enabled ? (
|
|||
|
|
<DndContext
|
|||
|
|
sensors={sensors}
|
|||
|
|
collisionDetection={closestCenter}
|
|||
|
|
onDragStart={handleDragStart}
|
|||
|
|
onDragEnd={handleDragEnd}
|
|||
|
|
onDragOver={handleDragOver}
|
|||
|
|
>
|
|||
|
|
{content}
|
|||
|
|
<DragOverlay>
|
|||
|
|
{activeId ? (
|
|||
|
|
<div className="opacity-0">
|
|||
|
|
{/* 空的覆盖层,让拖拽时只显示原节点的虚化效果 */}
|
|||
|
|
</div>
|
|||
|
|
) : null}
|
|||
|
|
</DragOverlay>
|
|||
|
|
</DndContext>
|
|||
|
|
) : content;
|
|||
|
|
|
|||
|
|
if (containerized) {
|
|||
|
|
return (
|
|||
|
|
<div className={`p-4 ${className}`}>
|
|||
|
|
<div className={`overflow-y-auto`} style={{ maxHeight }}>
|
|||
|
|
{wrappedContent}
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return <div className={className}>{wrappedContent}</div>;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 导出默认组件
|
|||
|
|
export default TreeSelector;
|