casualroom/apps/fenghuo/web/components/selector/dept-select.tsx

514 lines
15 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 { 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} />;
}