d4000cd292
2. 文档的基本信息修改改用接口。 3. 重新完善角色权限管理的页面逻辑。 4.将评查点列表中的返回逻辑改用浏览器的记忆返回。
319 lines
11 KiB
TypeScript
319 lines
11 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
|
|
type Option = {
|
|
value: string;
|
|
label: string;
|
|
children?: Option[];
|
|
};
|
|
|
|
type MultiCascaderProps = {
|
|
options: Option[];
|
|
defaultValue?: string[];
|
|
value?: string[];
|
|
onChange?: (value: string[]) => void;
|
|
placeholder?: string;
|
|
maxHeight?: number; // 下拉框最大高度,默认300px
|
|
searchable?: boolean; // 是否显示搜索框,默认false
|
|
searchPlaceholder?: string; // 搜索框占位符
|
|
};
|
|
|
|
// 获取所有叶子节点的值
|
|
const getAllLeafValues = (option: Option): string[] => {
|
|
if (!option.children || option.children.length === 0) {
|
|
return [option.value];
|
|
}
|
|
return option.children.flatMap(getAllLeafValues);
|
|
};
|
|
|
|
// 检查节点是否所有子节点都被选中
|
|
const isAllChildrenChecked = (option: Option, selected: string[]): boolean => {
|
|
if (!option.children || option.children.length === 0) {
|
|
return selected.includes(option.value);
|
|
}
|
|
// 对于有子节点的节点,检查其所有叶子节点是否都被选中
|
|
const leafValues = getAllLeafValues(option);
|
|
return leafValues.every(value => selected.includes(value));
|
|
};
|
|
|
|
// 检查节点是否有部分子节点被选中
|
|
const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => {
|
|
if (!option.children || option.children.length === 0) {
|
|
return selected.includes(option.value);
|
|
}
|
|
// 对于有子节点的节点,检查其叶子节点是否有部分被选中
|
|
const leafValues = getAllLeafValues(option);
|
|
return leafValues.some(value => selected.includes(value));
|
|
};
|
|
|
|
// 递归过滤选项,保留匹配项及其父级
|
|
const filterOptions = (options: Option[], keyword: string): Option[] => {
|
|
if (!keyword.trim()) return options;
|
|
|
|
const lowerKeyword = keyword.toLowerCase();
|
|
|
|
return options.reduce<Option[]>((acc, option) => {
|
|
// 检查当前节点是否匹配
|
|
const isCurrentMatch = option.label.toLowerCase().includes(lowerKeyword);
|
|
|
|
// 递归检查子节点
|
|
const filteredChildren = option.children ? filterOptions(option.children, keyword) : [];
|
|
|
|
// 如果当前节点匹配,或者有匹配的子节点,则保留
|
|
if (isCurrentMatch || filteredChildren.length > 0) {
|
|
acc.push({
|
|
...option,
|
|
children: isCurrentMatch
|
|
? option.children // 如果父节点匹配,保留所有子节点
|
|
: filteredChildren.length > 0
|
|
? filteredChildren // 如果只是子节点匹配,只保留匹配的子节点
|
|
: option.children
|
|
});
|
|
}
|
|
|
|
return acc;
|
|
}, []);
|
|
};
|
|
|
|
// 获取过滤后需要展开的所有父节点
|
|
const getExpandedKeysForFilter = (options: Option[], keyword: string): Set<string> => {
|
|
const keys = new Set<string>();
|
|
if (!keyword.trim()) return keys;
|
|
|
|
const lowerKeyword = keyword.toLowerCase();
|
|
|
|
const traverse = (opts: Option[], parentKeys: string[]) => {
|
|
for (const opt of opts) {
|
|
const isMatch = opt.label.toLowerCase().includes(lowerKeyword);
|
|
const hasChildren = opt.children && opt.children.length > 0;
|
|
|
|
if (hasChildren) {
|
|
// 递归检查子节点
|
|
const childMatches = traverse(opt.children!, [...parentKeys, opt.value]);
|
|
|
|
// 如果子节点有匹配,展开当前节点
|
|
if (childMatches) {
|
|
keys.add(opt.value);
|
|
parentKeys.forEach(k => keys.add(k));
|
|
}
|
|
}
|
|
|
|
// 如果当前节点匹配且有父节点,展开父节点
|
|
if (isMatch && parentKeys.length > 0) {
|
|
parentKeys.forEach(k => keys.add(k));
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
traverse(options, []);
|
|
return keys;
|
|
};
|
|
|
|
const MultiCascader: React.FC<MultiCascaderProps> = ({
|
|
options,
|
|
defaultValue = [],
|
|
value,
|
|
onChange,
|
|
placeholder = '请选择',
|
|
maxHeight = 300,
|
|
searchable = false,
|
|
searchPlaceholder = '搜索...'
|
|
}) => {
|
|
const [visible, setVisible] = useState(false);
|
|
const [selected, setSelected] = useState<string[]>(value ?? defaultValue);
|
|
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
|
const [searchKeyword, setSearchKeyword] = useState('');
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
// 当外部 value 变化时,同步内部状态
|
|
useEffect(() => {
|
|
if (value !== undefined) {
|
|
setSelected(value);
|
|
}
|
|
}, [value]);
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
|
setVisible(false);
|
|
}
|
|
};
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
|
}, []);
|
|
|
|
// 搜索关键词变化时自动展开匹配项
|
|
useEffect(() => {
|
|
if (searchKeyword.trim()) {
|
|
const keysToExpand = getExpandedKeysForFilter(options, searchKeyword);
|
|
setExpandedKeys(keysToExpand);
|
|
}
|
|
}, [searchKeyword, options]);
|
|
|
|
// 下拉框打开时聚焦搜索框
|
|
useEffect(() => {
|
|
if (visible && searchable && searchInputRef.current) {
|
|
setTimeout(() => searchInputRef.current?.focus(), 100);
|
|
}
|
|
if (!visible) {
|
|
setSearchKeyword(''); // 关闭时清空搜索
|
|
}
|
|
}, [visible, searchable]);
|
|
|
|
// 过滤后的选项
|
|
const filteredOptions = searchable ? filterOptions(options, searchKeyword) : options;
|
|
|
|
const handleItemCheck = (option: Option, checked: boolean) => {
|
|
const leafValues = getAllLeafValues(option);
|
|
let newSelected: string[];
|
|
|
|
if (checked) {
|
|
newSelected = Array.from(new Set([...selected, ...leafValues]));
|
|
} else {
|
|
newSelected = selected.filter(v => !leafValues.includes(v));
|
|
}
|
|
|
|
setSelected(newSelected);
|
|
onChange?.(newSelected);
|
|
};
|
|
|
|
const getDisplayText = () => {
|
|
if (selected.length === 0) return placeholder;
|
|
return `已选择 ${selected.length} 项`;
|
|
};
|
|
|
|
const toggleExpand = (key: string) => {
|
|
setExpandedKeys(prev => {
|
|
const newSet = new Set(prev);
|
|
if (newSet.has(key)) {
|
|
newSet.delete(key);
|
|
} else {
|
|
newSet.add(key);
|
|
}
|
|
return newSet;
|
|
});
|
|
};
|
|
|
|
const renderOption = (option: Option, level: number = 0) => {
|
|
const allChecked = isAllChildrenChecked(option, selected);
|
|
const someChecked = isSomeChildrenChecked(option, selected);
|
|
const hasChildren = option.children && option.children.length > 0;
|
|
const isExpanded = expandedKeys.has(option.value);
|
|
|
|
return (
|
|
<div key={option.value} className={level > 0 ? "pl-4" : ""}>
|
|
<div className="flex items-center py-1">
|
|
<input
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-gray-300"
|
|
style={{
|
|
accentColor: '#00684a', // 固定为绿色(烟草企业绿)
|
|
cursor: 'pointer'
|
|
}}
|
|
checked={allChecked}
|
|
ref={el => { if (el) el.indeterminate = !allChecked && someChecked; }}
|
|
onChange={e => handleItemCheck(option, e.target.checked)}
|
|
id={`cascader-${option.value}`}
|
|
/>
|
|
<label htmlFor={`cascader-${option.value}`} className="ml-2 text-sm flex-1 cursor-pointer">
|
|
{option.label}
|
|
</label>
|
|
{hasChildren && (
|
|
<button
|
|
type="button"
|
|
className="ml-2"
|
|
style={{ color: 'gray' }} // 展开图标固定为绿色
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
toggleExpand(option.value);
|
|
}}
|
|
>
|
|
<i className={isExpanded ? "ri-arrow-down-s-line" : "ri-arrow-right-s-line"}></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{hasChildren && isExpanded && (
|
|
<div className="ml-4 border-l border-gray-200 pl-2">
|
|
{option.children?.map(child => (
|
|
<React.Fragment key={child.value}>
|
|
{renderOption(child, level + 1)}
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
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={selected.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-3 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) => setSearchKeyword(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('');
|
|
}}
|
|
>
|
|
<i className="ri-close-line"></i>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 选项列表 */}
|
|
<div className="p-2 overflow-y-auto" style={{ maxHeight: `${maxHeight}px` }}>
|
|
{filteredOptions.length > 0 ? (
|
|
filteredOptions.map(option => renderOption(option))
|
|
) : (
|
|
<div className="text-center text-gray-400 py-4 text-sm">
|
|
<i className="ri-search-line text-lg block mb-1"></i>
|
|
无匹配结果
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default MultiCascader; |