| 1 | /** |
| 2 | * DateRangePicker - 全局日期范围选择器 |
| 3 | * 桌面端使用 Popover 模式,同时导出 DateRangeCalendar 供移动端内嵌使用 |
| 4 | */ |
| 5 | |
| 6 | 'use client' |
| 7 | |
| 8 | import dayjs from 'dayjs' |
| 9 | import { CalendarDays, ChevronLeft, ChevronRight } from 'lucide-react' |
| 10 | import { memo, useCallback, useMemo, useState } from 'react' |
| 11 | import { useTransClient } from '@/app/i18n/client' |
| 12 | import { Button } from '@/components/ui/button' |
| 13 | import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
| 14 | import { useGetClientLng } from '@/hooks/useSystem' |
| 15 | import { cn } from '@/utils/className' |
| 16 | |
| 17 | interface CalendarDay { |
| 18 | date: dayjs.Dayjs |
| 19 | isCurrentMonth: boolean |
| 20 | } |
| 21 | |
| 22 | interface DateRangeCalendarProps { |
| 23 | startDate: string | null |
| 24 | endDate: string | null |
| 25 | onStartChange: (date: string | null) => void |
| 26 | onEndChange: (date: string | null) => void |
| 27 | onClear?: () => void |
| 28 | /** 隐藏日历底部的清除按钮 */ |
| 29 | hideFooter?: boolean |
| 30 | className?: string |
| 31 | } |
| 32 | |
| 33 | interface DateRangePickerProps extends DateRangeCalendarProps { |
| 34 | className?: string |
| 35 | } |
| 36 | |
| 37 | /** 纯日历面板,供 Popover 和移动端内嵌使用 */ |
| 38 | export const DateRangeCalendar = memo(({ |
| 39 | startDate, |
| 40 | endDate, |
| 41 | onStartChange, |
| 42 | onEndChange, |
| 43 | onClear, |
| 44 | hideFooter, |
| 45 | className, |
| 46 | }: DateRangeCalendarProps) => { |
| 47 | const { t } = useTransClient('common') |
| 48 | const lng = useGetClientLng() |
| 49 | const [currentMonth, setCurrentMonth] = useState(() => |
| 50 | startDate ? dayjs(startDate) : dayjs(), |
| 51 | ) |
| 52 | const [hoverDate, setHoverDate] = useState<string | null>(null) |
| 53 | const [selectPhase, setSelectPhase] = useState<'start' | 'end'>( |
| 54 | startDate && !endDate ? 'end' : 'start', |
| 55 | ) |
| 56 | |
| 57 | const today = dayjs().format('YYYY-MM-DD') |
| 58 | |
| 59 | const weekDays = useMemo(() => { |
| 60 | const days: string[] = [] |
| 61 | for (let i = 0; i < 7; i++) { |
| 62 | days.push(dayjs().day(i).locale(lng).format('dd')) |
| 63 | } |
| 64 | return days |
| 65 | }, [lng]) |
| 66 | |
| 67 | const calendarDays = useMemo((): CalendarDay[] => { |
| 68 | const days: CalendarDay[] = [] |
| 69 | const daysInMonth = currentMonth.daysInMonth() |
| 70 | const firstDayOfMonth = currentMonth.startOf('month').day() |
| 71 | const prevMonthDays = currentMonth.subtract(1, 'month').daysInMonth() |
| 72 | |
| 73 | for (let i = firstDayOfMonth - 1; i >= 0; i--) { |
| 74 | days.push({ |
| 75 | date: currentMonth.subtract(1, 'month').date(prevMonthDays - i), |
| 76 | isCurrentMonth: false, |
| 77 | }) |
| 78 | } |
| 79 | for (let i = 1; i <= daysInMonth; i++) { |
| 80 | days.push({ |
| 81 | date: currentMonth.date(i), |
| 82 | isCurrentMonth: true, |
| 83 | }) |
| 84 | } |
| 85 | const remaining = 42 - days.length |
| 86 | for (let i = 1; i <= remaining; i++) { |
| 87 | days.push({ |
| 88 | date: currentMonth.add(1, 'month').date(i), |
| 89 | isCurrentMonth: false, |
| 90 | }) |
| 91 | } |
| 92 | return days |
| 93 | }, [currentMonth]) |
| 94 | |
| 95 | const handleDayClick = useCallback((dateStr: string) => { |
| 96 | if (selectPhase === 'start') { |
| 97 | onStartChange(dateStr) |
| 98 | onEndChange(null) |
| 99 | setSelectPhase('end') |
| 100 | } |
| 101 | else { |
| 102 | if (startDate && dateStr < startDate) { |
| 103 | onEndChange(startDate) |
| 104 | onStartChange(dateStr) |
| 105 | } |
| 106 | else { |
| 107 | onEndChange(dateStr) |
| 108 | } |
| 109 | setSelectPhase('start') |
| 110 | } |
| 111 | }, [selectPhase, startDate, onStartChange, onEndChange]) |
| 112 | |
| 113 | const handleClear = useCallback(() => { |
| 114 | onStartChange(null) |
| 115 | onEndChange(null) |
| 116 | setSelectPhase('start') |
| 117 | setHoverDate(null) |
| 118 | onClear?.() |
| 119 | }, [onStartChange, onEndChange, onClear]) |
| 120 | |
| 121 | const getDayState = useCallback((dateStr: string) => { |
| 122 | const isStart = dateStr === startDate |
| 123 | const isEnd = dateStr === endDate |
| 124 | const isToday = dateStr === today |
| 125 | |
| 126 | let inRange = false |
| 127 | let inPreview = false |
| 128 | |
| 129 | if (startDate && endDate) { |
| 130 | inRange = dateStr > startDate && dateStr < endDate |
| 131 | } |
| 132 | else if (startDate && !endDate && hoverDate && selectPhase === 'end') { |
| 133 | const rangeStart = hoverDate < startDate ? hoverDate : startDate |
| 134 | const rangeEnd = hoverDate < startDate ? startDate : hoverDate |
| 135 | inPreview = dateStr > rangeStart && dateStr < rangeEnd |
| 136 | } |
| 137 | |
| 138 | return { isStart, isEnd, isToday, inRange, inPreview } |
| 139 | }, [startDate, endDate, hoverDate, selectPhase, today]) |
| 140 | |
| 141 | return ( |
| 142 | <div className={cn('select-none', className)}> |
| 143 | {/* 月份导航 */} |
| 144 | <div className="flex items-center justify-between px-1 mb-2"> |
| 145 | <Button |
| 146 | variant="ghost" |
| 147 | size="icon" |
| 148 | className="size-8 cursor-pointer" |
| 149 | onClick={() => setCurrentMonth(m => m.subtract(1, 'month'))} |
| 150 | > |
| 151 | <ChevronLeft className="size-4" /> |
| 152 | </Button> |
| 153 | <span className="text-sm font-medium"> |
| 154 | {currentMonth.locale(lng).format('YYYY MMMM')} |
| 155 | </span> |
| 156 | <Button |
| 157 | variant="ghost" |
| 158 | size="icon" |
| 159 | className="size-8 cursor-pointer" |
| 160 | onClick={() => setCurrentMonth(m => m.add(1, 'month'))} |
| 161 | > |
| 162 | <ChevronRight className="size-4" /> |
| 163 | </Button> |
| 164 | </div> |
| 165 | |
| 166 | {/* 星期标题 */} |
| 167 | <div className="grid grid-cols-7 mb-1"> |
| 168 | {weekDays.map((day, i) => ( |
| 169 | <div key={i} className="h-9 flex items-center justify-center text-xs text-muted-foreground font-medium"> |
| 170 | {day} |
| 171 | </div> |
| 172 | ))} |
| 173 | </div> |
| 174 | |
| 175 | {/* 日期网格 */} |
| 176 | <div className="grid grid-cols-7"> |
| 177 | {calendarDays.map((item, i) => { |
| 178 | const dateStr = item.date.format('YYYY-MM-DD') |
| 179 | const { isStart, isEnd, isToday, inRange, inPreview } = getDayState(dateStr) |
| 180 | const isSelected = isStart || isEnd |
| 181 | const isRangeMiddle = inRange || inPreview |
| 182 | |
| 183 | return ( |
| 184 | <div |
| 185 | key={i} |
| 186 | className={cn( |
| 187 | 'relative flex items-center justify-center h-9', |
| 188 | isRangeMiddle && 'bg-primary/15', |
| 189 | isStart && endDate && 'rounded-l-full bg-primary/15', |
| 190 | isEnd && startDate && 'rounded-r-full bg-primary/15', |
| 191 | isStart && !endDate && !inPreview && 'bg-transparent', |
| 192 | isEnd && !startDate && 'bg-transparent', |
| 193 | )} |
| 194 | onMouseEnter={() => setHoverDate(dateStr)} |
| 195 | onMouseLeave={() => setHoverDate(null)} |
| 196 | > |
| 197 | <button |
| 198 | type="button" |
| 199 | onClick={() => handleDayClick(dateStr)} |
| 200 | className={cn( |
| 201 | 'relative size-9 rounded-full flex items-center justify-center text-sm transition-colors cursor-pointer', |
| 202 | !item.isCurrentMonth && 'opacity-40', |
| 203 | item.isCurrentMonth && !isSelected && !isRangeMiddle && 'hover:bg-accent', |
| 204 | item.isCurrentMonth && !isSelected && isRangeMiddle && 'text-primary hover:bg-primary/10', |
| 205 | isSelected && 'bg-primary text-gradient-foreground shadow-sm shadow-primary/20 hover:bg-primary/90 hover:text-gradient-foreground', |
| 206 | isToday && !isSelected && 'border border-primary', |
| 207 | )} |
| 208 | > |
| 209 | {item.date.date()} |
| 210 | </button> |
| 211 | </div> |
| 212 | ) |
| 213 | })} |
| 214 | </div> |
| 215 | |
| 216 | {/* 清除按钮 */} |
| 217 | {!hideFooter && (startDate || endDate) && ( |
| 218 | <div className="flex justify-end mt-2 pt-2 border-t"> |
| 219 | <Button |
| 220 | variant="ghost" |
| 221 | size="sm" |
| 222 | onClick={handleClear} |
| 223 | className="text-xs text-muted-foreground hover:text-foreground cursor-pointer h-7" |
| 224 | > |
| 225 | {t('datePicker.clear')} |
| 226 | </Button> |
| 227 | </div> |
| 228 | )} |
| 229 | </div> |
| 230 | ) |
| 231 | }) |
| 232 | |
| 233 | DateRangeCalendar.displayName = 'DateRangeCalendar' |
| 234 | |
| 235 | /** 桌面端 Popover 包装 */ |
| 236 | const DateRangePicker = memo(({ |
| 237 | startDate, |
| 238 | endDate, |
| 239 | onStartChange, |
| 240 | onEndChange, |
| 241 | className, |
| 242 | }: DateRangePickerProps) => { |
| 243 | const { t } = useTransClient('common') |
| 244 | const [open, setOpen] = useState(false) |
| 245 | const [localStart, setLocalStart] = useState<string | null>(startDate) |
| 246 | const [localEnd, setLocalEnd] = useState<string | null>(endDate) |
| 247 | |
| 248 | const handleOpenChange = useCallback((nextOpen: boolean) => { |
| 249 | if (nextOpen) { |
| 250 | setLocalStart(startDate) |
| 251 | setLocalEnd(endDate) |
| 252 | } |
| 253 | setOpen(nextOpen) |
| 254 | }, [startDate, endDate]) |
| 255 | |
| 256 | const handleConfirm = useCallback(() => { |
| 257 | onStartChange(localStart) |
| 258 | onEndChange(localEnd) |
| 259 | setOpen(false) |
| 260 | }, [localStart, localEnd, onStartChange, onEndChange]) |
| 261 | |
| 262 | const handleClear = useCallback(() => { |
| 263 | onStartChange(null) |
| 264 | onEndChange(null) |
| 265 | setOpen(false) |
| 266 | }, [onStartChange, onEndChange]) |
| 267 | |
| 268 | const displayText = useMemo(() => { |
| 269 | if (startDate && endDate) |
| 270 | return `${startDate} — ${endDate}` |
| 271 | if (startDate) |
| 272 | return `${startDate} —` |
| 273 | if (endDate) |
| 274 | return `— ${endDate}` |
| 275 | return t('datePicker.selectDateRange') |
| 276 | }, [startDate, endDate, t]) |
| 277 | |
| 278 | const hasValue = startDate || endDate |
| 279 | |
| 280 | return ( |
| 281 | <Popover open={open} onOpenChange={handleOpenChange}> |
| 282 | <PopoverTrigger asChild> |
| 283 | <button |
| 284 | type="button" |
| 285 | className={cn( |
| 286 | 'inline-flex items-center gap-2 h-8 px-3 rounded-md border border-input bg-background text-sm transition-colors hover:bg-accent cursor-pointer', |
| 287 | !hasValue && 'text-muted-foreground', |
| 288 | hasValue && 'text-foreground', |
| 289 | className, |
| 290 | )} |
| 291 | > |
| 292 | <CalendarDays className="size-3.5 shrink-0 text-muted-foreground" /> |
| 293 | <span className="truncate">{displayText}</span> |
| 294 | </button> |
| 295 | </PopoverTrigger> |
| 296 | <PopoverContent className="w-auto p-3" align="start"> |
| 297 | <DateRangeCalendar |
| 298 | startDate={localStart} |
| 299 | endDate={localEnd} |
| 300 | onStartChange={setLocalStart} |
| 301 | onEndChange={setLocalEnd} |
| 302 | hideFooter |
| 303 | /> |
| 304 | <div className="flex items-center justify-between mt-2 pt-2 border-t"> |
| 305 | {(localStart || localEnd) |
| 306 | ? ( |
| 307 | <Button |
| 308 | variant="ghost" |
| 309 | size="sm" |
| 310 | onClick={handleClear} |
| 311 | className="text-xs text-muted-foreground hover:text-foreground cursor-pointer h-7" |
| 312 | > |
| 313 | {t('datePicker.clear')} |
| 314 | </Button> |
| 315 | ) |
| 316 | : <span />} |
| 317 | <Button |
| 318 | size="sm" |
| 319 | onClick={handleConfirm} |
| 320 | className="text-xs cursor-pointer h-7" |
| 321 | > |
| 322 | {t('datePicker.confirm')} |
| 323 | </Button> |
| 324 | </div> |
| 325 | </PopoverContent> |
| 326 | </Popover> |
| 327 | ) |
| 328 | }) |
| 329 | |
| 330 | DateRangePicker.displayName = 'DateRangePicker' |
| 331 | |
| 332 | export default DateRangePicker |
| 333 |