Files
leaudit-platform-frontend/app/components/ui/MultiCascader.tsx
T
LiangShiyong b97d0e1a0b 1. 登录返回总公司,分公司,部门信息。
2. 修改角色权限管理的分配用户的数据渲染和接口。
3. 交叉评查任务的创建的组织架构组件的重构。
2026-01-21 10:04:04 +08:00

478 lines
15 KiB
TypeScript

import React, { useState, useEffect, useRef, useCallback } from 'react';
// 组织路径信息(懒加载接口返回)
export interface OrganizationPath {
tenant_name: string;
dep_name: string;
dep_short_name: string;
ou_name: string;
}
// 导入类型
export interface UserInfo {
id: number;
username: string;
nick_name: string;
area: string;
ou_id: string;
ou_name: string;
is_leader: boolean;
status: number;
// 以下字段在搜索接口中有值,在懒加载接口中为 null
tenant_name: string | null;
dep_name: string | null;
dep_short_name: string | null;
email?: string;
phone_number?: string;
// 懒加载接口返回的组织路径信息
organization_path?: OrganizationPath | null;
}
export interface TreeNodeItem {
label: string;
value: string;
isUser: boolean;
hasChildren: boolean;
userInfo?: UserInfo;
children?: TreeNodeItem[];
// 懒加载状态
isLoading?: boolean;
isLoaded?: boolean;
}
type MultiCascaderProps = {
options: TreeNodeItem[];
// 已选择的用户ID列表(用于判断按钮状态)
selectedUsers?: string[];
placeholder?: string;
maxHeight?: number;
searchable?: boolean;
searchPlaceholder?: string;
// 懒加载回调
onLoadChildren?: (node: TreeNodeItem) => Promise<TreeNodeItem[]>;
// 搜索用户回调
onSearchUsers?: (keyword: string) => Promise<TreeNodeItem[]>;
// 树数据变化回调(懒加载后同步更新父组件的树数据)
onTreeDataChange?: (treeData: TreeNodeItem[]) => void;
// 用户从树中添加时的回调
onAddUser?: (userNode: TreeNodeItem) => void;
// 用户从搜索结果添加时的回调
onSearchUserAdded?: (userNode: TreeNodeItem) => void;
};
const MultiCascader: React.FC<MultiCascaderProps> = ({
options,
selectedUsers = [],
placeholder = '请选择',
maxHeight = 300,
searchable = false,
searchPlaceholder = '搜索...',
onLoadChildren,
onSearchUsers,
onTreeDataChange,
onAddUser,
onSearchUserAdded
}) => {
const [visible, setVisible] = useState(false);
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
const [searchKeyword, setSearchKeyword] = useState('');
const [searchResults, setSearchResults] = useState<TreeNodeItem[]>([]);
const [searchLoading, setSearchLoading] = useState(false);
const [localOptions, setLocalOptions] = useState<TreeNodeItem[]>(options);
const containerRef = useRef<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const loadingNodesRef = useRef<Set<string>>(new Set());
const prevOptionsRef = useRef<TreeNodeItem[]>(options);
// 同步外部 options 变化
useEffect(() => {
setLocalOptions(options);
}, [options]);
// 同步树数据变化到父组件(懒加载后触发)
useEffect(() => {
// 只有当 localOptions 真正变化时才调用回调,避免无限循环
if (onTreeDataChange && localOptions !== prevOptionsRef.current) {
prevOptionsRef.current = localOptions;
onTreeDataChange(localOptions);
}
}, [localOptions, onTreeDataChange]);
// 点击外部关闭
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setVisible(false);
setSearchKeyword('');
setSearchResults([]);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// 下拉框打开时聚焦搜索框
useEffect(() => {
if (visible && searchable && searchInputRef.current) {
setTimeout(() => searchInputRef.current?.focus(), 100);
}
if (!visible) {
setSearchKeyword('');
setSearchResults([]);
}
}, [visible, searchable]);
// 搜索用户
const handleSearch = useCallback(async (keyword: string) => {
setSearchKeyword(keyword);
if (!keyword.trim() || !onSearchUsers) {
setSearchResults([]);
return;
}
setSearchLoading(true);
try {
const results = await onSearchUsers(keyword);
setSearchResults(results);
} catch (error) {
console.error('搜索用户失败:', error);
setSearchResults([]);
} finally {
setSearchLoading(false);
}
}, [onSearchUsers]);
// 防抖搜索
useEffect(() => {
const timer = setTimeout(() => {
if (searchKeyword && searchable && onSearchUsers) {
handleSearch(searchKeyword);
}
}, 300);
return () => clearTimeout(timer);
}, [searchKeyword, searchable, onSearchUsers, handleSearch]);
// 懒加载子节点
const handleExpand = async (node: TreeNodeItem) => {
// 如果已经加载过(有子组织或用户),则直接展开/折叠,不调用API
if (node.isLoaded) {
setExpandedKeys(prev => {
const newSet = new Set(prev);
if (newSet.has(node.value)) {
newSet.delete(node.value); // 折叠
} else {
newSet.add(node.value); // 展开
}
return newSet;
});
return;
}
// 正在加载中,直接展开已加载的部分
if (loadingNodesRef.current.has(node.value)) {
setExpandedKeys(prev => {
const newSet = new Set(prev);
newSet.add(node.value);
return newSet;
});
return;
}
// 标记为加载中
loadingNodesRef.current.add(node.value);
setLocalOptions(prev => updateNodeLoading(prev, node.value, true));
try {
if (onLoadChildren) {
const children = await onLoadChildren(node);
// 更新本地选项
setLocalOptions(prev => updateNodeChildren(prev, node.value, children, true));
}
// 展开节点
setExpandedKeys(prev => {
const newSet = new Set(prev);
newSet.add(node.value);
return newSet;
});
} catch (error) {
console.error('加载子节点失败:', error);
} finally {
loadingNodesRef.current.delete(node.value);
setLocalOptions(prev => updateNodeLoading(prev, node.value, false));
}
};
// 更新节点加载状态
const updateNodeLoading = (nodes: TreeNodeItem[], value: string, isLoading: boolean): TreeNodeItem[] => {
return nodes.map(node => {
if (node.value === value) {
return { ...node, isLoading };
}
if (node.children) {
return { ...node, children: updateNodeLoading(node.children, value, isLoading) };
}
return node;
});
};
// 更新节点的子节点
const updateNodeChildren = (
nodes: TreeNodeItem[],
value: string,
children: TreeNodeItem[],
isLoaded: boolean
): TreeNodeItem[] => {
return nodes.map(node => {
if (node.value === value) {
return {
...node,
children,
isLoaded,
hasChildren: children.length > 0
};
}
if (node.children) {
return {
...node,
children: updateNodeChildren(node.children, value, children, isLoaded)
};
}
return node;
});
};
// 检查用户是否已添加
const isUserAdded = (value: string): boolean => {
return selectedUsers.includes(value);
};
// 从树中添加用户
const handleAddUser = (userNode: TreeNodeItem) => {
if (isUserAdded(userNode.value)) {
return; // 已添加,不处理
}
onAddUser?.(userNode);
};
// 从搜索结果添加用户
const handleAddSearchUser = (userNode: TreeNodeItem) => {
if (isUserAdded(userNode.value)) {
return; // 已添加,不处理
}
onSearchUserAdded?.(userNode);
};
// 渲染组织/用户节点
const renderNode = (node: TreeNodeItem, level: number = 0, parentKey: string = ''): React.ReactNode => {
const hasChildren = node.hasChildren;
const isExpanded = expandedKeys.has(node.value);
const isLoading = node.isLoading;
const isLoaded = node.isLoaded;
const isAdded = isUserAdded(node.value);
const nodeKey = parentKey ? `${parentKey}-${node.value}` : node.value;
return (
<div key={nodeKey} className={level > 0 ? "ml-4" : ""}>
{/* 组织节点:点击整行可展开/收缩 */}
{hasChildren ? (
<div
className="flex items-center py-1 px-2 rounded cursor-pointer hover:bg-gray-100 transition-colors"
onClick={() => handleExpand(node)}
>
{/* 展开/折叠图标 */}
{isLoading ? (
<i className="ri-loader-4-line animate-spin text-gray-400 mr-2"></i>
) : isExpanded ? (
<i className="ri-arrow-down-s-line text-gray-400 mr-2"></i>
) : (
<i className="ri-arrow-right-s-line text-gray-400 mr-2"></i>
)}
{/* 节点标签 */}
<span className="text-sm flex-1" title={node.label}>
{node.label}
</span>
</div>
) : (
/* 用户节点 */
<div className="flex items-center py-1 px-2 rounded hover:bg-gray-100 transition-colors">
{/* 用户节点:显示添加按钮 */}
<span className="text-sm flex-1" title={node.label}>
{node.label}
</span>
{node.isUser && (
<button
type="button"
className={`ml-2 w-6 h-6 rounded flex items-center justify-center text-sm ${
isAdded
? 'bg-gray-200 text-gray-400 cursor-not-allowed'
: 'bg-green-800 text-white hover:bg-green-700'
}`}
onClick={(e) => {
e.stopPropagation();
handleAddUser(node);
}}
disabled={isAdded}
title={isAdded ? '已添加' : '添加成员'}
>
{isAdded ? '✓' : '+'}
</button>
)}
</div>
)}
{/* 渲染子节点 - 只有当有实际子节点时才渲染 */}
{node.children && node.children.length > 0 && isExpanded && (
<div className="ml-4 border-l border-gray-200 pl-2">
{node.children.map(child => renderNode(child, level + 1, nodeKey))}
</div>
)}
</div>
);
};
// 渲染搜索结果
const renderSearchResults = () => {
if (searchLoading) {
return (
<div className="p-4 text-center text-gray-400 text-sm">
<i className="ri-loader-4-line animate-spin text-lg block mb-1"></i>
...
</div>
);
}
if (searchResults.length === 0) {
return (
<div className="p-4 text-center text-gray-400 text-sm">
<i className="ri-search-line text-lg block mb-1"></i>
</div>
);
}
return (
<div className="p-2">
<div className="text-xs text-gray-500 mb-2 px-1">
{searchResults.length}
</div>
{searchResults.map(user => (
<div
key={user.value}
className="flex items-center justify-between py-2 px-2 hover:bg-gray-50 rounded"
>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-900 truncate">
{user.userInfo?.nick_name || user.label}
</div>
<div className="text-xs text-gray-500 truncate">
{/* 优先使用 organization_path 中的值(懒加载接口),为空则使用顶层字段(搜索接口) */}
{user.userInfo?.organization_path?.tenant_name || user.userInfo?.tenant_name} · {user.userInfo?.ou_name}
</div>
</div>
<button
type="button"
onClick={() => handleAddSearchUser(user)}
className={`ml-2 px-3 py-1 text-xs rounded ${
isUserAdded(user.value)
? 'bg-gray-200 text-gray-500 cursor-not-allowed'
: 'bg-green-800 text-white hover:bg-green-700'
}`}
disabled={isUserAdded(user.value)}
>
{isUserAdded(user.value) ? '已添加' : '添加'}
</button>
</div>
))}
</div>
);
};
// 显示文本
const getDisplayText = () => {
if (selectedUsers.length === 0) return placeholder;
return `已选择 ${selectedUsers.length} 位成员`;
};
return (
<div ref={containerRef} className="relative inline-block w-full">
{/* 触发器 */}
<div
className="border border-gray-300 rounded px-3 py-2 cursor-pointer bg-white flex justify-between items-center focus:outline-none focus:ring-2 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]"
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setVisible(!visible);
}
}}
onClick={() => setVisible(!visible)}
>
<span className={selectedUsers.length === 0 ? 'text-gray-400' : ''}>
{getDisplayText()}
</span>
<span className="text-gray-400">
<i className={visible ? "ri-arrow-up-s-line" : "ri-arrow-down-s-line"}></i>
</span>
</div>
{/* 下拉面板 */}
{visible && (
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full">
{/* 搜索框 */}
{searchable && (
<div className="p-2 border-b border-gray-200">
<div className="relative">
<i className="ri-search-line absolute left-2 top-1/2 -translate-y-1/2 text-gray-400"></i>
<input
ref={searchInputRef}
type="text"
className="w-full pl-8 pr-8 py-1.5 text-sm border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-[var(--color-primary,#00684a)] focus:border-[var(--color-primary,#00684a)]"
placeholder={searchPlaceholder}
value={searchKeyword}
onChange={(e) => handleSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{searchKeyword && (
<button
type="button"
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
onClick={(e) => {
e.stopPropagation();
setSearchKeyword('');
setSearchResults([]);
}}
>
<i className="ri-close-line"></i>
</button>
)}
</div>
</div>
)}
{/* 内容区域 */}
<div className="p-2 overflow-y-auto" style={{ maxHeight: `${maxHeight}px` }}>
{searchKeyword && searchable ? (
renderSearchResults()
) : localOptions.length > 0 ? (
localOptions.map(option => renderNode(option))
) : (
<div className="text-center text-gray-400 py-4 text-sm">
</div>
)}
</div>
</div>
)}
</div>
);
};
export default MultiCascader;