514 lines
15 KiB
TypeScript
Executable File
514 lines
15 KiB
TypeScript
Executable File
'use client';
|
||
|
||
import * as React from 'react';
|
||
import { useState } from 'react';
|
||
import { Button } from '@nice/ui/components/button';
|
||
import { Popover, PopoverContent, PopoverTrigger } from '@nice/ui/components/popover';
|
||
import {
|
||
Command,
|
||
CommandEmpty,
|
||
CommandGroup,
|
||
CommandInput,
|
||
CommandItem,
|
||
CommandList,
|
||
} from '@nice/ui/components/command';
|
||
import { IconChevronDown, IconChevronRight, IconCheck, IconBuilding, IconX } from '@tabler/icons-react';
|
||
import { cn } from '@nice/ui/lib/utils';
|
||
import { useTRPC } from '@fenghuo/client';
|
||
import { useQuery } from '@tanstack/react-query';
|
||
import type { OrganizationTreeNode } from '@fenghuo/common';
|
||
import { Organization } from '@fenghuo/db';
|
||
import { Badge } from '@nice/ui/components/badge';
|
||
|
||
// 树形选择器属性
|
||
interface OrganizationTreeSelectorProps {
|
||
value?: string | string[]; // 支持单选和多选
|
||
onValueChange?: (value: string | string[]) => void;
|
||
placeholder?: string;
|
||
className?: string;
|
||
disabled?: boolean;
|
||
allowClear?: boolean;
|
||
multiple?: boolean; // 是否支持多选
|
||
excludeIds?: string[]; // 排除的部门ID列表,用于编辑时排除自身及子部门
|
||
modal?: boolean; // 是否为模态模式,用于在 Dialog 中解决滚轮问题
|
||
}
|
||
|
||
// 树形节点组件
|
||
interface TreeNodeProps {
|
||
node: OrganizationTreeNode;
|
||
level: number;
|
||
selectedValue?: string | string[];
|
||
multiple?: boolean;
|
||
expandedIds: Set<string>;
|
||
onSelect: (value: string, name: string) => void;
|
||
onToggleExpand: (id: string) => void;
|
||
}
|
||
|
||
function TreeNode({ node, level, selectedValue, multiple, expandedIds, onSelect, onToggleExpand }: TreeNodeProps) {
|
||
const hasChildren = node.children && node.children.length > 0;
|
||
const isExpanded = expandedIds.has(node.id);
|
||
|
||
// 判断是否选中 - 兼容单选和多选
|
||
const isSelected = React.useMemo(() => {
|
||
if (!selectedValue) return false;
|
||
return Array.isArray(selectedValue) ? selectedValue.includes(node.id) : selectedValue === node.id;
|
||
}, [selectedValue, node.id]);
|
||
|
||
const handleSelect = () => {
|
||
onSelect(node.id, node.name);
|
||
};
|
||
|
||
const handleToggleExpand = (e: React.MouseEvent) => {
|
||
e.stopPropagation();
|
||
if (hasChildren) {
|
||
onToggleExpand(node.id);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<CommandItem
|
||
value={`${node.id}-${node.name}`}
|
||
onSelect={handleSelect}
|
||
className={cn(
|
||
'flex items-center justify-between cursor-pointer',
|
||
'hover:bg-accent hover:text-accent-foreground',
|
||
isSelected && 'bg-accent text-accent-foreground',
|
||
)}
|
||
style={{ paddingLeft: `${level * 16 + 8}px` }}
|
||
>
|
||
<div className="flex items-center gap-2 flex-1">
|
||
{/* 展开/折叠图标 */}
|
||
<div className="w-4 h-4 flex items-center justify-center">
|
||
{hasChildren ? (
|
||
<button onClick={handleToggleExpand} className="hover:bg-muted/50 rounded-sm p-0.5">
|
||
{isExpanded ? <IconChevronDown className="h-3 w-3" /> : <IconChevronRight className="h-3 w-3" />}
|
||
</button>
|
||
) : (
|
||
<div className="w-3 h-3" />
|
||
)}
|
||
</div>
|
||
|
||
{/* 部门图标 */}
|
||
<IconBuilding className="h-4 w-4 text-muted-foreground" />
|
||
|
||
{/* 部门名称 */}
|
||
<span className="flex-1 truncate">{node.name}</span>
|
||
</div>
|
||
|
||
{/* 选中状态指示 */}
|
||
{isSelected && <IconCheck className="h-4 w-4 text-primary" />}
|
||
</CommandItem>
|
||
|
||
{/* 子节点 */}
|
||
{hasChildren && isExpanded && (
|
||
<>
|
||
{node.children!.map((child) => (
|
||
<TreeNode
|
||
key={child.id}
|
||
node={child}
|
||
level={level + 1}
|
||
selectedValue={selectedValue}
|
||
multiple={multiple}
|
||
expandedIds={expandedIds}
|
||
onSelect={onSelect}
|
||
onToggleExpand={onToggleExpand}
|
||
/>
|
||
))}
|
||
</>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 主组件
|
||
export function DeptSelect({
|
||
value,
|
||
onValueChange,
|
||
placeholder = '选择部门',
|
||
className,
|
||
disabled = false,
|
||
allowClear = true,
|
||
multiple = false,
|
||
excludeIds = [],
|
||
modal = false,
|
||
}: OrganizationTreeSelectorProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
||
const [searchValue, setSearchValue] = useState('');
|
||
|
||
const trpc = useTRPC();
|
||
|
||
// 使用 tRPC 获取组织树数据
|
||
const {
|
||
data: rawOrganizationTree = [],
|
||
isLoading,
|
||
error,
|
||
} = useQuery({
|
||
...trpc.organization.getTree.queryOptions({
|
||
includeInactive: false,
|
||
}),
|
||
});
|
||
|
||
// 过滤排除的部门
|
||
const organizationTree = React.useMemo(() => {
|
||
const filterExcluded = (nodes: OrganizationTreeNode[]): OrganizationTreeNode[] => {
|
||
return nodes
|
||
.filter((node) => !excludeIds.includes(node.id))
|
||
.map((node) => ({
|
||
...node,
|
||
children: node.children ? filterExcluded(node.children) : [],
|
||
}));
|
||
};
|
||
return filterExcluded(rawOrganizationTree);
|
||
}, [rawOrganizationTree, excludeIds]);
|
||
|
||
// 构建部门树 - 由于后端已经返回树形结构并排序,客户端只需处理过滤后的树
|
||
const buildOrganizationTree = React.useMemo(() => {
|
||
if (!organizationTree || organizationTree.length === 0) {
|
||
return [];
|
||
}
|
||
return organizationTree;
|
||
}, [organizationTree]);
|
||
|
||
// 扁平化所有部门,用于查找和路径计算
|
||
const flatOrganizations = React.useMemo(() => {
|
||
const flatten = (nodes: OrganizationTreeNode[]): Organization[] => {
|
||
return nodes.reduce<Organization[]>((acc, node) => {
|
||
acc.push(node);
|
||
if (node.children) {
|
||
acc.push(...flatten(node.children));
|
||
}
|
||
return acc;
|
||
}, []);
|
||
};
|
||
return flatten(organizationTree);
|
||
}, [organizationTree]);
|
||
|
||
// 获取选中的部门列表
|
||
const selectedOrganizations = React.useMemo(() => {
|
||
if (!value) return [];
|
||
|
||
const selectedIds = Array.isArray(value) ? value : [value];
|
||
return flatOrganizations.filter((org) => selectedIds.includes(org.id));
|
||
}, [flatOrganizations, value]);
|
||
|
||
// 获取部门的完整路径
|
||
const getOrganizationPath = React.useCallback(
|
||
(deptId: string): string => {
|
||
const dept = flatOrganizations.find((d) => d.id === deptId);
|
||
if (!dept) return '';
|
||
|
||
const path: string[] = [];
|
||
let current: Organization | undefined = dept;
|
||
|
||
while (current) {
|
||
path.unshift(current.name);
|
||
current = flatOrganizations.find((d) => d.id === current?.parentId);
|
||
}
|
||
|
||
return path.join(' / ');
|
||
},
|
||
[flatOrganizations],
|
||
);
|
||
|
||
// 切换展开状态
|
||
const toggleExpand = (id: string) => {
|
||
setExpandedIds((prev) => {
|
||
const newSet = new Set(prev);
|
||
if (newSet.has(id)) {
|
||
newSet.delete(id);
|
||
} else {
|
||
newSet.add(id);
|
||
}
|
||
return newSet;
|
||
});
|
||
};
|
||
|
||
// 处理选择逻辑
|
||
const handleSelect = (selectedValue: string, selectedName: string) => {
|
||
if (!onValueChange) return;
|
||
|
||
if (multiple) {
|
||
const currentValues = Array.isArray(value) ? value : value ? [value] : [];
|
||
|
||
if (currentValues.includes(selectedValue)) {
|
||
// 取消选择
|
||
const newValues = currentValues.filter((id) => id !== selectedValue);
|
||
onValueChange(newValues);
|
||
} else {
|
||
// 添加选择
|
||
onValueChange([...currentValues, selectedValue]);
|
||
}
|
||
} else {
|
||
// 单选模式
|
||
onValueChange(selectedValue);
|
||
setOpen(false);
|
||
}
|
||
};
|
||
|
||
// 处理清除
|
||
const handleClear = (e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
onValueChange?.(multiple ? [] : '');
|
||
};
|
||
|
||
// 处理单个部门移除(仅多选模式)
|
||
const handleRemoveOrganization = (orgId: string, e: React.MouseEvent) => {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
if (multiple) {
|
||
const currentValues = Array.isArray(value) ? value : value ? [value] : [];
|
||
const newValues = currentValues.filter((id) => id !== orgId);
|
||
onValueChange?.(newValues);
|
||
}
|
||
};
|
||
|
||
// 过滤部门(支持搜索)
|
||
const filterOrganizations = React.useCallback(
|
||
(nodes: OrganizationTreeNode[], search: string): OrganizationTreeNode[] => {
|
||
if (!search) return nodes;
|
||
|
||
const filtered: OrganizationTreeNode[] = [];
|
||
|
||
for (const node of nodes) {
|
||
const matches = node.name.toLowerCase().includes(search.toLowerCase());
|
||
const filteredChildren = node.children ? filterOrganizations(node.children, search) : [];
|
||
|
||
if (matches || filteredChildren.length > 0) {
|
||
filtered.push({
|
||
...node,
|
||
children: filteredChildren.length > 0 ? filteredChildren : node.children,
|
||
});
|
||
}
|
||
}
|
||
|
||
return filtered;
|
||
},
|
||
[],
|
||
);
|
||
|
||
const filteredTree = React.useMemo(() => {
|
||
return filterOrganizations(buildOrganizationTree, searchValue);
|
||
}, [buildOrganizationTree, searchValue, filterOrganizations]);
|
||
|
||
// 处理搜索时的自动展开逻辑
|
||
React.useEffect(() => {
|
||
if (searchValue) {
|
||
// 在useEffect内部计算过滤结果,避免依赖filteredTree
|
||
const filtered = filterOrganizations(buildOrganizationTree, searchValue);
|
||
|
||
if (filtered.length > 0) {
|
||
const idsToExpand = new Set<string>();
|
||
|
||
const collectExpandIds = (nodes: OrganizationTreeNode[]) => {
|
||
nodes.forEach(node => {
|
||
// 如果节点名称匹配搜索条件,展开该节点
|
||
if (node.name.toLowerCase().includes(searchValue.toLowerCase())) {
|
||
idsToExpand.add(node.id);
|
||
}
|
||
|
||
// 如果有子节点,展开该节点并递归处理子节点
|
||
if (node.children && node.children.length > 0) {
|
||
idsToExpand.add(node.id);
|
||
collectExpandIds(node.children);
|
||
}
|
||
});
|
||
};
|
||
|
||
collectExpandIds(filtered);
|
||
|
||
// 批量更新展开状态
|
||
setExpandedIds(prev => new Set([...prev, ...idsToExpand]));
|
||
}
|
||
}
|
||
}, [searchValue, buildOrganizationTree, filterOrganizations]);
|
||
|
||
// 渲染触发器内容
|
||
const renderTriggerContent = () => {
|
||
if (selectedOrganizations.length === 0) {
|
||
return placeholder;
|
||
}
|
||
|
||
if (multiple) {
|
||
if (selectedOrganizations.length === 1) {
|
||
return getOrganizationPath(selectedOrganizations[0]!.id);
|
||
} else {
|
||
return (
|
||
<div className="flex flex-wrap gap-1 justify-start">
|
||
{selectedOrganizations.slice(0, 2).map((org) => (
|
||
<Badge key={org.id} variant="secondary" className="text-xs px-1.5 py-0.5 h-5 max-w-[120px]">
|
||
<span className="truncate">{org.name}</span>
|
||
<span
|
||
onClick={(e) => handleRemoveOrganization(org.id, e)}
|
||
className="ml-1 hover:bg-muted-foreground/20 rounded-sm p-0.5 cursor-pointer inline-flex items-center justify-center"
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label={`移除部门 ${org.name}`}
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
handleRemoveOrganization(org.id, e as any);
|
||
}
|
||
}}
|
||
>
|
||
<IconX className="h-2.5 w-2.5" />
|
||
</span>
|
||
</Badge>
|
||
))}
|
||
{selectedOrganizations.length > 2 && (
|
||
<Badge variant="secondary" className="text-xs px-1.5 py-0.5 h-5">
|
||
+{selectedOrganizations.length - 2}
|
||
</Badge>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
}
|
||
|
||
return getOrganizationPath(selectedOrganizations[0]!.id);
|
||
};
|
||
|
||
// 检查是否选中
|
||
const isSelected = (orgId: string) => {
|
||
if (!value) return false;
|
||
return Array.isArray(value) ? value.includes(orgId) : value === orgId;
|
||
};
|
||
|
||
// 加载状态
|
||
if (isLoading) {
|
||
return (
|
||
<Button
|
||
variant="outline"
|
||
className={cn('w-[150px] h-8 justify-between text-sm font-normal text-muted-foreground', className)}
|
||
disabled
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<IconBuilding className="h-4 w-4 text-muted-foreground" />
|
||
<span>加载中...</span>
|
||
</div>
|
||
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
// 错误状态
|
||
if (error) {
|
||
return (
|
||
<Button
|
||
variant="outline"
|
||
className={cn('w-[150px] h-8 justify-between text-sm font-normal text-muted-foreground', className)}
|
||
disabled
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<IconBuilding className="h-4 w-4 text-muted-foreground" />
|
||
<span>加载失败</span>
|
||
</div>
|
||
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||
</Button>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<Popover open={open} onOpenChange={setOpen} modal={modal}>
|
||
<PopoverTrigger asChild>
|
||
<Button
|
||
variant="outline"
|
||
role="combobox"
|
||
aria-expanded={open}
|
||
className={cn(
|
||
'w-full justify-start text-left font-normal',
|
||
!selectedOrganizations.length && 'text-muted-foreground',
|
||
className,
|
||
)}
|
||
disabled={disabled}
|
||
>
|
||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||
<IconBuilding className="h-4 w-4 text-muted-foreground shrink-0" />
|
||
<div className="flex-1 min-w-0 text-left">{renderTriggerContent()}</div>
|
||
</div>
|
||
<div className="flex items-center gap-1 shrink-0 ml-auto">
|
||
{allowClear && selectedOrganizations.length > 0 && (
|
||
<span
|
||
onClick={handleClear}
|
||
className="flex items-center justify-center w-4 h-4 rounded-sm hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
|
||
role="button"
|
||
tabIndex={0}
|
||
aria-label="清除选择"
|
||
onKeyDown={(e) => {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
handleClear(e as any);
|
||
}
|
||
}}
|
||
>
|
||
<IconX className="h-3 w-3" />
|
||
</span>
|
||
)}
|
||
<IconChevronDown className="h-4 w-4 shrink-0 opacity-50" />
|
||
</div>
|
||
</Button>
|
||
</PopoverTrigger>
|
||
<PopoverContent className="p-0 w-full" style={{ width: 'var(--radix-popover-trigger-width)' }} align="start">
|
||
<Command shouldFilter={false}>
|
||
<CommandInput placeholder="搜索部门..." value={searchValue} onValueChange={setSearchValue} className="h-9" />
|
||
<CommandList className="max-h-[300px]">
|
||
<CommandEmpty>未找到部门</CommandEmpty>
|
||
<CommandGroup>
|
||
{/* 多选模式下显示已选择数量 */}
|
||
{multiple && selectedOrganizations.length > 0 && (
|
||
<div className="px-2 py-1.5 text-xs text-muted-foreground border-b">
|
||
已选择 {selectedOrganizations.length} 个部门
|
||
</div>
|
||
)}
|
||
{/* 树形部门列表 */}
|
||
{filteredTree.map((node) => (
|
||
<TreeNode
|
||
key={node.id}
|
||
node={node}
|
||
level={0}
|
||
selectedValue={value}
|
||
multiple={multiple}
|
||
expandedIds={expandedIds}
|
||
onSelect={handleSelect}
|
||
onToggleExpand={toggleExpand}
|
||
/>
|
||
))}
|
||
</CommandGroup>
|
||
</CommandList>
|
||
</Command>
|
||
</PopoverContent>
|
||
</Popover>
|
||
);
|
||
}
|
||
|
||
// 导出便捷的单选和多选组件
|
||
export function SingleDeptSelector(
|
||
props: Omit<OrganizationTreeSelectorProps, 'multiple' | 'onValueChange'> & {
|
||
onValueChange?: (value: string) => void;
|
||
},
|
||
) {
|
||
const handleValueChange = (value: string | string[]) => {
|
||
if (props.onValueChange && typeof value === 'string') {
|
||
props.onValueChange(value);
|
||
}
|
||
};
|
||
|
||
return <DeptSelect {...props} multiple={false} onValueChange={handleValueChange} />;
|
||
}
|
||
|
||
export function MultipleDeptSelector(
|
||
props: Omit<OrganizationTreeSelectorProps, 'multiple' | 'onValueChange'> & {
|
||
onValueChange?: (value: string[]) => void;
|
||
},
|
||
) {
|
||
const handleValueChange = (value: string | string[]) => {
|
||
if (props.onValueChange && Array.isArray(value)) {
|
||
props.onValueChange(value);
|
||
}
|
||
};
|
||
|
||
return <DeptSelect {...props} multiple={true} onValueChange={handleValueChange} />;
|
||
}
|