| 1 | /** |
| 2 | * StarRating - 星星评分组件 |
| 3 | * 功能:支持悬停预览、点击选择评分,可配置尺寸和交互模式 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import { Star } from 'lucide-react' |
| 9 | import { useState } from 'react' |
| 10 | import { cn } from '@/utils/className' |
| 11 | |
| 12 | /** 尺寸映射 */ |
| 13 | const sizeMap = { |
| 14 | sm: 'w-5 h-5', |
| 15 | md: 'w-6 h-6', |
| 16 | lg: 'w-8 h-8', |
| 17 | } |
| 18 | |
| 19 | export interface StarRatingProps { |
| 20 | /** 当前评分值 */ |
| 21 | value?: number | null |
| 22 | /** 评分变化回调 */ |
| 23 | onChange?: (value: number) => void |
| 24 | /** 星星总数,默认 5 */ |
| 25 | count?: number |
| 26 | /** 星星尺寸,默认 'md' */ |
| 27 | size?: 'sm' | 'md' | 'lg' |
| 28 | /** 是否禁用交互 */ |
| 29 | disabled?: boolean |
| 30 | /** 是否只读(仅展示,无悬停效果) */ |
| 31 | readOnly?: boolean |
| 32 | /** 自定义类名 */ |
| 33 | className?: string |
| 34 | } |
| 35 | |
| 36 | export function StarRating({ |
| 37 | value = null, |
| 38 | onChange, |
| 39 | count = 5, |
| 40 | size = 'md', |
| 41 | disabled = false, |
| 42 | readOnly = false, |
| 43 | className, |
| 44 | }: StarRatingProps) { |
| 45 | const [hoverValue, setHoverValue] = useState<number | null>(null) |
| 46 | |
| 47 | // 显示的评分:悬停时用 hoverValue,否则用实际 value |
| 48 | const displayValue = hoverValue ?? value |
| 49 | // 是否可交互 |
| 50 | const isInteractive = !disabled && !readOnly && !!onChange |
| 51 | |
| 52 | return ( |
| 53 | <div |
| 54 | className={cn('flex items-center gap-1', className)} |
| 55 | onMouseLeave={() => isInteractive && setHoverValue(null)} |
| 56 | > |
| 57 | {Array.from({ length: count }).map((_, idx) => { |
| 58 | const starValue = idx + 1 |
| 59 | const isActive = displayValue !== null && displayValue >= starValue |
| 60 | |
| 61 | return ( |
| 62 | <button |
| 63 | key={starValue} |
| 64 | type="button" |
| 65 | disabled={disabled} |
| 66 | onClick={() => isInteractive && onChange?.(starValue)} |
| 67 | onMouseEnter={() => isInteractive && setHoverValue(starValue)} |
| 68 | className={cn( |
| 69 | 'p-1 rounded transition-colors', |
| 70 | isInteractive && 'cursor-pointer hover:bg-muted', |
| 71 | isActive ? 'text-amber-400' : 'text-muted-foreground', |
| 72 | disabled && 'cursor-not-allowed opacity-50', |
| 73 | )} |
| 74 | aria-label={`${starValue} star`} |
| 75 | > |
| 76 | <Star className={sizeMap[size]} fill={isActive ? 'currentColor' : 'none'} /> |
| 77 | </button> |
| 78 | ) |
| 79 | })} |
| 80 | </div> |
| 81 | ) |
| 82 | } |
| 83 | |
| 84 | export default StarRating |
| 85 |