casualroom/apps/fenghuo/web/components/selector/tree-selector.tsx

720 lines
19 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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;