diff --git a/app/components/ui/MultiCascader.tsx b/app/components/ui/MultiCascader.tsx new file mode 100644 index 0000000..e40bea0 --- /dev/null +++ b/app/components/ui/MultiCascader.tsx @@ -0,0 +1,175 @@ +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; +}; + +// 获取所有叶子节点的值 +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 MultiCascader: React.FC = ({ + options, + defaultValue = [], + value, + onChange, + placeholder = '请选择' +}) => { + const [visible, setVisible] = useState(false); + const [selected, setSelected] = useState(value ?? defaultValue); + const [expandedKeys, setExpandedKeys] = useState>(new Set()); + const containerRef = useRef(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); + }, []); + + 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 ( +
0 ? "pl-4" : ""}> +
+ { if (el) el.indeterminate = !allChecked && someChecked; }} + onChange={e => handleItemCheck(option, e.target.checked)} + id={`cascader-${option.value}`} + /> + + {hasChildren && ( + + )} +
+ {hasChildren && isExpanded && ( +
+ {option.children?.map(child => ( + + {renderOption(child, level + 1)} + + ))} +
+ )} +
+ ); + }; + + return ( +
+
setVisible(!visible)} + > + + {getDisplayText()} + + + + +
+ {visible && ( +
+
+ {options.map(option => renderOption(option))} +
+
+ )} +
+ ); +}; + +export default MultiCascader; \ No newline at end of file