返回 AiToEarn
index.tsx
1 /**
2 * DatePicker - 全局单日期选择器
3 * Popover + 日历面板,点击日期即选中并关闭
4 */
5
6 'use client'
7
8 import dayjs from 'dayjs'
9 import { CalendarDays, ChevronLeft, ChevronRight, X } 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 DatePickerProps {
23 value: string | null
24 onChange: (date: string | null) => void
25 placeholder?: string
26 /** 最小可选日期,格式 YYYY-MM-DD */
27 minDate?: string
28 className?: string
29 }
30
31 const DatePicker = memo(({
32 value,
33 onChange,
34 placeholder,
35 minDate,
36 className,
37 }: DatePickerProps) => {
38 const { t } = useTransClient('common')
39 const lng = useGetClientLng()
40 const [open, setOpen] = useState(false)
41 const [currentMonth, setCurrentMonth] = useState(() =>
42 value ? dayjs(value) : dayjs(),
43 )
44
45 const today = dayjs().format('YYYY-MM-DD')
46
47 const weekDays = useMemo(() => {
48 const days: string[] = []
49 for (let i = 0; i < 7; i++) {
50 days.push(dayjs().day(i).locale(lng).format('dd'))
51 }
52 return days
53 }, [lng])
54
55 const calendarDays = useMemo((): CalendarDay[] => {
56 const days: CalendarDay[] = []
57 const daysInMonth = currentMonth.daysInMonth()
58 const firstDayOfMonth = currentMonth.startOf('month').day()
59 const prevMonthDays = currentMonth.subtract(1, 'month').daysInMonth()
60
61 for (let i = firstDayOfMonth - 1; i >= 0; i--) {
62 days.push({
63 date: currentMonth.subtract(1, 'month').date(prevMonthDays - i),
64 isCurrentMonth: false,
65 })
66 }
67 for (let i = 1; i <= daysInMonth; i++) {
68 days.push({
69 date: currentMonth.date(i),
70 isCurrentMonth: true,
71 })
72 }
73 const remaining = 42 - days.length
74 for (let i = 1; i <= remaining; i++) {
75 days.push({
76 date: currentMonth.add(1, 'month').date(i),
77 isCurrentMonth: false,
78 })
79 }
80 return days
81 }, [currentMonth])
82
83 const handleDayClick = useCallback((dateStr: string) => {
84 if (minDate && dateStr < minDate)
85 return
86 onChange(dateStr)
87 setOpen(false)
88 }, [onChange, minDate])
89
90 const handleClear = useCallback((e: React.MouseEvent) => {
91 e.stopPropagation()
92 onChange(null)
93 }, [onChange])
94
95 const handleOpenChange = useCallback((nextOpen: boolean) => {
96 if (nextOpen && value) {
97 setCurrentMonth(dayjs(value))
98 }
99 setOpen(nextOpen)
100 }, [value])
101
102 const displayText = value || placeholder || t('datePicker.selectDate')
103
104 return (
105 <Popover open={open} onOpenChange={handleOpenChange}>
106 <PopoverTrigger asChild>
107 <button
108 type="button"
109 className={cn(
110 'inline-flex items-center gap-2 h-9 w-full px-3 rounded-md border border-input bg-background text-sm transition-colors hover:bg-accent cursor-pointer',
111 !value && 'text-muted-foreground',
112 value && 'text-foreground',
113 className,
114 )}
115 >
116 <CalendarDays className="size-3.5 shrink-0 text-muted-foreground" />
117 <span className="truncate flex-1 text-left">{displayText}</span>
118 {value && (
119 <X
120 className="size-3.5 shrink-0 text-muted-foreground hover:text-foreground"
121 onClick={handleClear}
122 />
123 )}
124 </button>
125 </PopoverTrigger>
126 <PopoverContent className="w-auto p-3" align="start">
127 <div className="select-none">
128 {/* 月份导航 */}
129 <div className="flex items-center justify-between px-1 mb-2">
130 <Button
131 variant="ghost"
132 size="icon"
133 className="size-8 cursor-pointer"
134 onClick={() => setCurrentMonth(m => m.subtract(1, 'month'))}
135 >
136 <ChevronLeft className="size-4" />
137 </Button>
138 <span className="text-sm font-medium">
139 {currentMonth.locale(lng).format('YYYY MMMM')}
140 </span>
141 <Button
142 variant="ghost"
143 size="icon"
144 className="size-8 cursor-pointer"
145 onClick={() => setCurrentMonth(m => m.add(1, 'month'))}
146 >
147 <ChevronRight className="size-4" />
148 </Button>
149 </div>
150
151 {/* 星期标题 */}
152 <div className="grid grid-cols-7 mb-1">
153 {weekDays.map((day, i) => (
154 <div key={i} className="h-9 flex items-center justify-center text-xs text-muted-foreground font-medium">
155 {day}
156 </div>
157 ))}
158 </div>
159
160 {/* 日期网格 */}
161 <div className="grid grid-cols-7">
162 {calendarDays.map((item, i) => {
163 const dateStr = item.date.format('YYYY-MM-DD')
164 const isSelected = dateStr === value
165 const isToday = dateStr === today
166 const isDisabled = minDate ? dateStr < minDate : false
167
168 return (
169 <div key={i} className="flex items-center justify-center h-9">
170 <button
171 type="button"
172 onClick={() => handleDayClick(dateStr)}
173 disabled={isDisabled}
174 className={cn(
175 'size-9 rounded-full flex items-center justify-center text-sm transition-colors cursor-pointer',
176 !item.isCurrentMonth && 'opacity-40',
177 item.isCurrentMonth && !isSelected && !isDisabled && 'hover:bg-accent',
178 isSelected && 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/20 hover:shadow-md hover:shadow-primary/25',
179 isToday && !isSelected && 'border border-primary',
180 isDisabled && 'opacity-25 cursor-not-allowed',
181 )}
182 >
183 {item.date.date()}
184 </button>
185 </div>
186 )
187 })}
188 </div>
189 </div>
190 </PopoverContent>
191 </Popover>
192 )
193 })
194
195 DatePicker.displayName = 'DatePicker'
196
197 export default DatePicker
198
198 lines Plain Text