b97d0e1a0b
2. 修改角色权限管理的分配用户的数据渲染和接口。 3. 交叉评查任务的创建的组织架构组件的重构。
478 lines
15 KiB
TypeScript
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;
|