/// /** * Rating Component - Lynx 版 MUI Rating * 100% 一比一复刻 MUI Rating * * 星级评分组件,支持小数评分和自定义图标 * * 对应 MUI: packages/mui-material/src/Rating/Rating.js */ import './Rating.css' import ratingClasses, { getRatingUtilityClass } from './ratingClasses' export { ratingClasses, getRatingUtilityClass } // ============================================= // 类型定义 // ============================================= export type RatingSize = 'small' | 'medium' | 'large' export interface RatingProps { /** 自定义类名 */ className?: string /** 样式类覆盖 */ classes?: Partial /** 默认评分 */ defaultValue?: number /** 是否禁用 */ disabled?: boolean /** 空图标 */ emptyIcon?: any /** 空值标签 */ emptyLabelText?: string /** 最大评分 */ max?: number /** 当前评分 */ value?: number /** 精度 */ precision?: number /** 是否只读 */ readOnly?: boolean /** 尺寸 */ size?: RatingSize /** 获取标签文本 */ getLabelText?: (value: number) => string /** 高精度图标 */ icon?: any /** 活动图标 */ activeIcon?: any /** 内联样式 */ style?: Record /** sx 属性 */ sx?: Record /** 点击事件 */ bindtap?: (event?: any) => void onChange?: (event: any, value: number | null) => void onChangeActive?: (event: any, value: number) => void onHover?: (event: any, value: number) => void } // ============================================= // 辅助函数 // ============================================= function capitalize(str: string): string { return str.charAt(0).toUpperCase() + str.slice(1) } function composeClasses( slots: Record, getUtilityClass: (slot: string) => string, classes?: Record ): Record { const output: Record = {} Object.keys(slots).forEach((slot) => { output[slot] = slots[slot] .filter(Boolean) .map((key) => { if (classes && classes[key as string]) { return `${getUtilityClass(key as string)} ${classes[key as string]}` } return getUtilityClass(key as string) }) .join(' ') }) return output } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max) } function getDecimalPrecision(num: number): number { const decimalPart = num.toString().split('.')[1] return decimalPart ? decimalPart.length : 0 } function roundValueToPrecision(value: number, precision: number): number { if (value == null) { return value } const nearest = Math.round(value / precision) * precision return Number(nearest.toFixed(getDecimalPrecision(precision))) } // ============================================= // useUtilityClasses // ============================================= interface OwnerState extends RatingProps { emptyValueFocused?: boolean focusVisible?: boolean } function useUtilityClasses(ownerState: OwnerState) { const { classes, size = 'medium', readOnly, disabled, emptyValueFocused, focusVisible } = ownerState const slots = { root: [ 'root', `size${capitalize(size)}`, disabled && 'disabled', focusVisible && 'focusVisible', readOnly && 'readOnly', ], label: ['label', 'pristine'], labelEmptyValue: [emptyValueFocused && 'labelEmptyValueActive'], icon: ['icon'], iconEmpty: ['iconEmpty'], iconFilled: ['iconFilled'], iconHover: ['iconHover'], iconFocus: ['iconFocus'], iconActive: ['iconActive'], decimal: ['decimal'], visuallyHidden: ['visuallyHidden'], } return composeClasses(slots, getRatingUtilityClass, classes) } // ============================================= // Rating 组件 // ============================================= export function Rating(props: RatingProps) { const { className, classes: classesProp, defaultValue = 0, disabled = false, emptyIcon, emptyLabelText = 'Empty', max = 5, value: valueProp, precision = 1, readOnly = false, size = 'medium', getLabelText = (value: number) => `${value} Star${value !== 1 ? 's' : ''}`, icon, activeIcon, style, sx, bindtap, onChange, onChangeActive, onHover, ...other } = props // 在 Lynx 环境下,组件必须是受控的 // 如果没有提供 value 属性,使用默认值 const value = valueProp !== undefined ? valueProp : defaultValue const ownerState: OwnerState = { ...props, value, disabled, readOnly, size, } const classes = useUtilityClasses(ownerState) // 处理点击事件 const handleTap = (event?: any, newValue?: number) => { if (disabled || readOnly) return if (newValue !== undefined) { const clampedValue = clamp(newValue, 0, max) const roundedValue = roundValueToPrecision(clampedValue, precision) if (onChange) { onChange(event, roundedValue === 0 ? null : roundedValue) } } if (bindtap) bindtap(event) } // 处理悬停事件 const handleHover = (event?: any, newValue?: number) => { if (disabled || readOnly) return if (onHover && newValue !== undefined) { onHover(event, newValue) } } // 渲染星星图标 const renderStars = () => { const stars = [] const clampedValue = clamp(value, 0, max) for (let index = 0; index < max; index += 1) { const itemValue = index + 1 // 判断是否填充 const isFilled = clampedValue >= itemValue // 判断是否部分填充(小数) const isFraction = clampedValue > index && clampedValue < itemValue stars.push( handleTap(e, itemValue)} style={{ cursor: disabled || readOnly ? 'default' : 'pointer' }} > {/* 使用 CSS 实现星星图标 */} ) } return stars } // 构建类名 const rootClasses = [ classes.root, ratingClasses.root, className, ratingClasses[`size${capitalize(size)}` as keyof typeof ratingClasses], disabled && ratingClasses.disabled, readOnly && ratingClasses.readOnly, ].filter(Boolean).join(' ') return ( {getLabelText(value)} {renderStars()} ) } export default Rating