Merge branch 'shiy-login' of http://git.7bm.co:1024/leke/docreview into shiy-login
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
type Option = {
|
||||
value: string;
|
||||
label: string;
|
||||
children?: Option[];
|
||||
};
|
||||
|
||||
type CascaderProps = {
|
||||
options: Option[];
|
||||
defaultValue?: string[];
|
||||
onChange?: (value: string[]) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
const Cascader: React.FC<CascaderProps> = ({ options, defaultValue = [], onChange, placeholder = 'Select' }) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>(defaultValue);
|
||||
const [menus, setMenus] = useState<Option[][]>([options]);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
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 handleItemClick = (option: Option, level: number) => {
|
||||
const newSelected = selected.slice(0, level).concat(option.value);
|
||||
setSelected(newSelected);
|
||||
if (option.children) {
|
||||
setMenus(menus.slice(0, level + 1).concat([option.children]));
|
||||
} else {
|
||||
setMenus(menus.slice(0, level + 1));
|
||||
setVisible(false);
|
||||
onChange?.(newSelected);
|
||||
}
|
||||
};
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selected.length === 0) return placeholder;
|
||||
let currentOptions = options;
|
||||
return selected.map(val => {
|
||||
const opt = currentOptions.find(o => o.value === val);
|
||||
currentOptions = opt?.children || [];
|
||||
return opt?.label || '';
|
||||
}).join(' / ');
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative inline-block">
|
||||
<div
|
||||
className="border border-gray-300 rounded px-3 py-1 cursor-pointer"
|
||||
onClick={() => setVisible(!visible)}
|
||||
>
|
||||
{getDisplayText()}
|
||||
</div>
|
||||
{visible && (
|
||||
<div className="absolute z-10 bg-white border border-gray-300 rounded shadow-lg mt-1 flex">
|
||||
{menus.map((menu, level) => (
|
||||
<ul key={level} className="list-none m-0 p-0 min-w-[150px] border-r border-gray-200 last:border-r-0">
|
||||
{menu.map(option => (
|
||||
<li
|
||||
key={option.value}
|
||||
className={`px-3 py-1 cursor-pointer hover:bg-gray-100 ${selected[level] === option.value ? 'bg-gray-200' : ''}`}
|
||||
onClick={() => handleItemClick(option, level)}
|
||||
>
|
||||
{option.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Cascader;
|
||||
@@ -16,6 +16,17 @@ export interface DateRangePickerProps {
|
||||
colorMode?: 'light' | 'dark' | 'auto';
|
||||
}
|
||||
|
||||
export interface SingleDatePickerProps {
|
||||
date: string;
|
||||
onDateChange: (value: string) => void;
|
||||
label?: string;
|
||||
className?: string;
|
||||
id?: string;
|
||||
format?: string;
|
||||
placeholder?: string;
|
||||
colorMode?: 'light' | 'dark' | 'auto';
|
||||
}
|
||||
|
||||
export function links() {
|
||||
return [
|
||||
{ rel: "stylesheet", href: dateRangePickerStyles }
|
||||
@@ -199,6 +210,117 @@ export function DateRangePicker({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单日期选择器组件
|
||||
* 用于选择单个日期
|
||||
*/
|
||||
export function SingleDatePicker({
|
||||
date,
|
||||
onDateChange,
|
||||
label = "日期",
|
||||
className = "",
|
||||
id = "date-single",
|
||||
format = "yyyy-MM-dd",
|
||||
placeholder = "请选择日期",
|
||||
colorMode = 'auto'
|
||||
}: SingleDatePickerProps) {
|
||||
// 使用ref获取input元素
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 添加状态跟踪日期输入框是否被聚焦
|
||||
const [isFocused, setIsFocused] = useState<boolean>(false);
|
||||
|
||||
// 格式化显示日期
|
||||
const formattedDate = date ? formatDate(date, format) : "";
|
||||
|
||||
// 保持原始日期输入框的显示格式
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.setAttribute('data-display-value', formattedDate || placeholder);
|
||||
}
|
||||
}, [formattedDate, placeholder]);
|
||||
|
||||
// 处理日期输入框全局点击
|
||||
const handleWrapperClick = () => {
|
||||
if (inputRef.current) {
|
||||
// 点击整个包装器区域时,触发输入框点击
|
||||
inputRef.current.focus();
|
||||
inputRef.current.click();
|
||||
|
||||
try {
|
||||
// 检查并使用showPicker API
|
||||
const hasShowPicker = typeof inputRef.current.showPicker === 'function';
|
||||
|
||||
// 尝试使用showPicker API
|
||||
if (hasShowPicker) {
|
||||
inputRef.current.showPicker();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to show date picker:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleWrapperClick();
|
||||
}
|
||||
};
|
||||
|
||||
// 处理聚焦和失焦
|
||||
const handleFocus = () => {
|
||||
setIsFocused(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsFocused(false);
|
||||
};
|
||||
|
||||
// 获取颜色模式类名
|
||||
const getColorModeClass = () => {
|
||||
if (colorMode === 'light') return 'color-mode-light';
|
||||
if (colorMode === 'dark') return 'color-mode-dark';
|
||||
return ''; // auto模式不添加额外类名
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`date-picker single-date-picker ${getColorModeClass()} ${className}`}>
|
||||
<div className="date-field">
|
||||
<label htmlFor={id} className="date-label">{label}</label>
|
||||
<div
|
||||
className={`date-input-wrapper ${isFocused ? 'focused' : ''}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleWrapperClick();
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
aria-label={`选择${label}`}
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
type="date"
|
||||
className="date-input"
|
||||
value={date}
|
||||
onChange={(e) => onDateChange(e.target.value)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
<div
|
||||
className={`date-display ${!formattedDate ? 'placeholder' : ''}`}
|
||||
>
|
||||
{formattedDate || placeholder}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 简化版日期范围选择器,适用于紧凑布局,不显示标签
|
||||
export function SimpleDateRangePicker({
|
||||
startDate,
|
||||
@@ -344,4 +466,4 @@ export function SimpleDateRangePicker({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
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<MultiCascaderProps> = ({
|
||||
options,
|
||||
defaultValue = [],
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '请选择'
|
||||
}) => {
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [selected, setSelected] = useState<string[]>(value ?? defaultValue);
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<string>>(new Set());
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div key={option.value} className={level > 0 ? "pl-4" : ""}>
|
||||
<div className="flex items-center py-1">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-checkbox h-4 w-4 text-[var(--color-primary,#00684a)] rounded border-gray-300 focus:ring-[var(--color-primary,#00684a)]"
|
||||
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">
|
||||
{option.label}
|
||||
</label>
|
||||
{hasChildren && (
|
||||
<button
|
||||
type="button"
|
||||
className="ml-2 text-gray-400 hover:text-gray-600"
|
||||
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 max-h-100 overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{options.map(option => renderOption(option))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MultiCascader;
|
||||
Reference in New Issue
Block a user