fix: 1. 继续对齐交叉评查的接口,完善创建交叉评查的逻辑 和 相关组件的渲染布局。
2. 文档的基本信息修改改用接口。 3. 重新完善角色权限管理的页面逻辑。 4.将评查点列表中的返回逻辑改用浏览器的记忆返回。
This commit is contained in:
@@ -12,6 +12,9 @@ type MultiCascaderProps = {
|
||||
value?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
maxHeight?: number; // 下拉框最大高度,默认300px
|
||||
searchable?: boolean; // 是否显示搜索框,默认false
|
||||
searchPlaceholder?: string; // 搜索框占位符
|
||||
};
|
||||
|
||||
// 获取所有叶子节点的值
|
||||
@@ -42,17 +45,87 @@ const isSomeChildrenChecked = (option: Option, selected: string[]): boolean => {
|
||||
return leafValues.some(value => selected.includes(value));
|
||||
};
|
||||
|
||||
const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
options,
|
||||
defaultValue = [],
|
||||
// 递归过滤选项,保留匹配项及其父级
|
||||
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 = '请选择'
|
||||
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(() => {
|
||||
@@ -71,6 +144,27 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
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[];
|
||||
@@ -175,9 +269,46 @@ const MultiCascader: React.FC<MultiCascaderProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
{visible && (
|
||||
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 w-full max-h-100 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{options.map(option => renderOption(option))}
|
||||
<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>
|
||||
)}
|
||||
|
||||
+120
-53
@@ -19,6 +19,10 @@ interface TableProps<T> {
|
||||
emptyText?: React.ReactNode;
|
||||
className?: string;
|
||||
onRow?: (record: T, index: number) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
scroll?: {
|
||||
x?: number | string;
|
||||
y?: number | string;
|
||||
};
|
||||
}
|
||||
|
||||
export function Table<T extends Record<string, any>>({
|
||||
@@ -30,6 +34,7 @@ export function Table<T extends Record<string, any>>({
|
||||
emptyText = '暂无数据',
|
||||
className = '',
|
||||
onRow,
|
||||
scroll,
|
||||
}: TableProps<T>) {
|
||||
// 防御性检查:确保 dataSource 始终是数组
|
||||
const safeDataSource = dataSource || [];
|
||||
@@ -41,63 +46,125 @@ export function Table<T extends Record<string, any>>({
|
||||
return String(record[rowKey]);
|
||||
};
|
||||
|
||||
// 是否启用固定表头滚动
|
||||
const hasScrollY = scroll?.y !== undefined;
|
||||
|
||||
// 渲染表头
|
||||
const renderHeader = () => (
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={column.key || column.dataIndex?.toString() || index}
|
||||
className={column.className}
|
||||
style={{
|
||||
width: column.width,
|
||||
textAlign: column.align || 'left',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
);
|
||||
|
||||
// 渲染表体
|
||||
const renderBody = () => (
|
||||
<tbody>
|
||||
{safeDataSource.length > 0 ? (
|
||||
safeDataSource.map((record, index) => (
|
||||
<tr
|
||||
key={getRowKey(record, index)}
|
||||
{...(onRow ? onRow(record, index) : {})}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key || column.dataIndex?.toString() || colIndex}
|
||||
style={{ textAlign: column.align || 'left' }}
|
||||
>
|
||||
{column.render
|
||||
? column.render(
|
||||
column.dataIndex ? record[column.dataIndex] : undefined,
|
||||
record,
|
||||
index
|
||||
)
|
||||
: column.dataIndex
|
||||
? record[column.dataIndex]
|
||||
: undefined}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="ant-table-empty py-6 text-center text-gray-500"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
);
|
||||
|
||||
// 如果有垂直滚动,使用固定表头布局
|
||||
if (hasScrollY) {
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'ant-table-loading' : ''}`}>
|
||||
<div className="ant-table-container">
|
||||
{/* 固定表头 */}
|
||||
<div className="ant-table-header">
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`} style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col key={index} style={{ width: column.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
{renderHeader()}
|
||||
</table>
|
||||
</div>
|
||||
{/* 可滚动表体 */}
|
||||
<div
|
||||
className="ant-table-body"
|
||||
style={{
|
||||
maxHeight: typeof scroll.y === 'number' ? `${scroll.y}px` : scroll.y,
|
||||
overflowY: 'auto',
|
||||
overflowX: scroll?.x ? 'auto' : 'hidden'
|
||||
}}
|
||||
>
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`} style={{ tableLayout: 'fixed' }}>
|
||||
<colgroup>
|
||||
{columns.map((column, index) => (
|
||||
<col key={index} style={{ width: column.width }} />
|
||||
))}
|
||||
</colgroup>
|
||||
{renderBody()}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && (
|
||||
<div className="ant-table-loading-indicator">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-5 h-5 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
<span className="text-gray-600">加载中...</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 默认布局(无滚动)
|
||||
return (
|
||||
<div className={`ant-table-wrapper ${className} ${loading ? 'ant-table-loading' : ''}`}>
|
||||
<table className={`ant-table ${bordered ? 'ant-table-bordered' : ''}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((column, index) => (
|
||||
<th
|
||||
key={column.key || column.dataIndex?.toString() || index}
|
||||
className={column.className}
|
||||
style={{
|
||||
width: column.width,
|
||||
textAlign: column.align || 'left',
|
||||
}}
|
||||
>
|
||||
{column.title}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{safeDataSource.length > 0 ? (
|
||||
safeDataSource.map((record, index) => (
|
||||
<tr
|
||||
key={getRowKey(record, index)}
|
||||
{...(onRow ? onRow(record, index) : {})}
|
||||
>
|
||||
{columns.map((column, colIndex) => (
|
||||
<td
|
||||
key={column.key || column.dataIndex?.toString() || colIndex}
|
||||
style={{ textAlign: column.align || 'left' }}
|
||||
>
|
||||
{column.render
|
||||
? column.render(
|
||||
column.dataIndex ? record[column.dataIndex] : undefined,
|
||||
record,
|
||||
index
|
||||
)
|
||||
: column.dataIndex
|
||||
? record[column.dataIndex]
|
||||
: undefined}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={columns.length}
|
||||
className="ant-table-empty py-6 text-center text-gray-500"
|
||||
>
|
||||
{emptyText}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
{renderHeader()}
|
||||
{renderBody()}
|
||||
</table>
|
||||
|
||||
|
||||
{loading && (
|
||||
<div className="ant-table-loading-indicator">
|
||||
<div className="flex items-center space-x-2">
|
||||
|
||||
Reference in New Issue
Block a user