返回 AiToEarn
index.tsx
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
333 lines Plain Text