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

720 lines
19 KiB
TypeScript
Raw Normal View History

2025-07-28 07:50:50 +08:00
'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;