返回 AiToEarn
DateTimePicker.tsx
1 /**
2 * DateTimePicker Component
3 * 自定义日期时间选择器组件 - 支持深色/浅色主题切换
4 */
5
6 import type { Dayjs } from 'dayjs'
7 import dayjs from 'dayjs'
8 import { ChevronLeft, ChevronRight } from 'lucide-react'
9 import { useEffect, useRef, useState } from 'react'
10 import { useTransClient } from '@/app/i18n/client'
11 import { Button } from '@/components/ui/button'
12 import { cn } from '@/utils/className'
13
14 interface DateTimePickerProps {
15 value: Dayjs | null
16 onChange: (date: Dayjs) => void
17 disabledDate?: (current: Dayjs) => boolean
18 disabledTime?: (current: Dayjs | null) => {
19 disabledHours?: () => number[]
20 disabledMinutes?: (hour: number) => number[]
21 }
22 // 是否为移动端
23 isMobile?: boolean
24 }
25
26 export function DateTimePicker({
27 value,
28 onChange,
29 disabledDate,
30 disabledTime,
31 isMobile,
32 }: DateTimePickerProps) {
33 const { t } = useTransClient('publish')
34 const [currentMonth, setCurrentMonth] = useState(value || dayjs())
35 const [selectedDate, setSelectedDate] = useState(value)
36 const [selectedHour, setSelectedHour] = useState(value?.hour() || 0)
37 const [selectedMinute, setSelectedMinute] = useState(value?.minute() || 0)
38
39 const hourScrollRef = useRef<HTMLDivElement>(null)
40 const minuteScrollRef = useRef<HTMLDivElement>(null)
41
42 useEffect(() => {
43 if (!value) {
44 setCurrentMonth(dayjs())
45 setSelectedDate(null)
46 setSelectedHour(0)
47 setSelectedMinute(0)
48 return
49 }
50
51 setCurrentMonth(value)
52 setSelectedDate(value)
53 setSelectedHour(value.hour())
54 setSelectedMinute(value.minute())
55 }, [value])
56
57 const daysInMonth = currentMonth.daysInMonth()
58 const firstDayOfMonth = currentMonth.startOf('month').day()
59 const today = dayjs()
60
61 // 自动滚动到选中的时间
62 useEffect(() => {
63 if (hourScrollRef.current) {
64 const selectedElement = hourScrollRef.current.querySelector('[data-selected="true"]')
65 if (selectedElement) {
66 selectedElement.scrollIntoView({ block: 'center', behavior: 'smooth' })
67 }
68 }
69 }, [selectedHour])
70
71 useEffect(() => {
72 if (minuteScrollRef.current) {
73 const selectedElement = minuteScrollRef.current.querySelector('[data-selected="true"]')
74 if (selectedElement) {
75 selectedElement.scrollIntoView({ block: 'center', behavior: 'smooth' })
76 }
77 }
78 }, [selectedMinute])
79
80 // 生成日历网格
81 const calendarDays = []
82 const prevMonthDays = currentMonth.subtract(1, 'month').daysInMonth()
83
84 // 上个月的日期
85 for (let i = firstDayOfMonth - 1; i >= 0; i--) {
86 calendarDays.push({
87 date: currentMonth.subtract(1, 'month').date(prevMonthDays - i),
88 isCurrentMonth: false,
89 })
90 }
91
92 // 当前月的日期
93 for (let i = 1; i <= daysInMonth; i++) {
94 calendarDays.push({
95 date: currentMonth.date(i),
96 isCurrentMonth: true,
97 })
98 }
99
100 // 下个月的日期(补齐到42天,6周)
101 const remainingDays = 42 - calendarDays.length
102 for (let i = 1; i <= remainingDays; i++) {
103 calendarDays.push({
104 date: currentMonth.add(1, 'month').date(i),
105 isCurrentMonth: false,
106 })
107 }
108
109 const handlePrevMonth = () => {
110 setCurrentMonth(currentMonth.subtract(1, 'month'))
111 }
112
113 const handleNextMonth = () => {
114 setCurrentMonth(currentMonth.add(1, 'month'))
115 }
116
117 const handleSelectDate = (date: Dayjs) => {
118 if (disabledDate && disabledDate(date))
119 return
120
121 setSelectedDate(date)
122 const newDateTime = date.hour(selectedHour).minute(selectedMinute)
123 onChange(newDateTime)
124 }
125
126 const handleTimeChange = (hour: number, minute: number) => {
127 setSelectedHour(hour)
128 setSelectedMinute(minute)
129 if (selectedDate) {
130 const newDateTime = selectedDate.hour(hour).minute(minute)
131 onChange(newDateTime)
132 }
133 }
134
135 // 获取禁用的小时和分钟
136 const disabledTimeConfig = disabledTime ? disabledTime(selectedDate) : {}
137 const disabledHours = disabledTimeConfig.disabledHours?.() || []
138 const disabledMinutes = disabledTimeConfig.disabledMinutes?.(selectedHour) || []
139
140 const weekDays = [
141 t('dateTimePicker.sunday'),
142 t('dateTimePicker.monday'),
143 t('dateTimePicker.tuesday'),
144 t('dateTimePicker.wednesday'),
145 t('dateTimePicker.thursday'),
146 t('dateTimePicker.friday'),
147 t('dateTimePicker.saturday'),
148 ]
149
150 // 格式化年月显示
151 const formatYearMonth = (date: Dayjs) => {
152 const year = date.year()
153 const month = date.month() + 1 // dayjs 的月份是 0-11
154 return t('dateTimePicker.yearMonth', { year, month })
155 }
156
157 return (
158 <div className={cn('flex', isMobile ? 'flex-col' : 'flex-row')}>
159 {/* 左侧日历部分 */}
160 <div className={cn('flex-1 p-4', !isMobile && 'border-r border-border')}>
161 {/* 月份导航 */}
162 <div className="flex items-center justify-between mb-4">
163 <Button
164 variant="ghost"
165 size="sm"
166 onClick={handlePrevMonth}
167 className="h-8 w-8 p-0 cursor-pointer"
168 >
169 <ChevronLeft className="w-4 h-4" />
170 </Button>
171 <div className="text-sm font-medium">{formatYearMonth(currentMonth)}</div>
172 <Button
173 variant="ghost"
174 size="sm"
175 onClick={handleNextMonth}
176 className="h-8 w-8 p-0 cursor-pointer"
177 >
178 <ChevronRight className="w-4 h-4" />
179 </Button>
180 </div>
181
182 {/* 星期标题 */}
183 <div className="grid grid-cols-7 gap-1 mb-2">
184 {weekDays.map(day => (
185 <div key={day} className="text-center text-xs font-medium text-muted-foreground py-2">
186 {day}
187 </div>
188 ))}
189 </div>
190
191 {/* 日期网格 */}
192 <div className="grid grid-cols-7 gap-1">
193 {calendarDays.map((item, index) => {
194 const isToday = item.date.isSame(today, 'day')
195 const isSelected = selectedDate?.isSame(item.date, 'day')
196 const isDisabled = !item.isCurrentMonth || (disabledDate && disabledDate(item.date))
197
198 return (
199 <button
200 key={index}
201 onClick={() => handleSelectDate(item.date)}
202 disabled={isDisabled}
203 className={cn(
204 'h-9 w-full text-sm rounded-md transition-colors cursor-pointer',
205 'hover:bg-accent hover:text-accent-foreground',
206 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
207 !item.isCurrentMonth && 'text-muted-foreground opacity-50',
208 isToday && 'border border-primary',
209 isSelected
210 && 'bg-gradient-back text-gradient-foreground shadow-sm hover:bg-gradient-back hover:text-gradient-foreground',
211 isDisabled && 'opacity-30 cursor-not-allowed hover:bg-transparent',
212 )}
213 >
214 {item.date.date()}
215 </button>
216 )
217 })}
218 </div>
219 </div>
220
221 {/* 右侧/底部时间选择部分 */}
222 <div
223 className={cn(
224 'flex flex-col',
225 isMobile ? 'h-[200px] border-t border-border' : 'w-[120px] h-[350px]',
226 )}
227 >
228 {/* 时间显示 */}
229 <div
230 className={cn(
231 'p-3 shrink-0',
232 isMobile ? 'border-b border-border' : 'border-b border-border',
233 )}
234 >
235 <div className="text-center">
236 <div className="text-lg font-semibold tabular-nums">
237 {selectedHour.toString().padStart(2, '0')}
238 :
239 {selectedMinute.toString().padStart(2, '0')}
240 </div>
241 </div>
242 </div>
243
244 {/* 时间选择滚动列表 */}
245 <div className="flex-1 flex min-h-0">
246 {/* 小时列 */}
247 <div
248 ref={hourScrollRef}
249 className="flex-1 border-r border-border overflow-y-auto overscroll-contain scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent hover:scrollbar-thumb-muted-foreground/40"
250 >
251 {Array.from({ length: 24 }, (_, i) => i).map((hour) => {
252 const isDisabled = disabledHours.includes(hour)
253 const isSelected = hour === selectedHour
254 return (
255 <div
256 key={hour}
257 data-selected={isSelected}
258 onClick={() => !isDisabled && handleTimeChange(hour, selectedMinute)}
259 className={cn(
260 'w-full text-center text-sm transition-colors tabular-nums cursor-pointer select-none',
261 'hover:bg-accent hover:text-accent-foreground',
262 isSelected && 'bg-primary/10 text-primary font-semibold ring-1 ring-primary/20',
263 isDisabled && 'opacity-30 cursor-not-allowed hover:bg-transparent',
264 isMobile ? 'py-3' : 'py-2',
265 )}
266 >
267 {hour.toString().padStart(2, '0')}
268 </div>
269 )
270 })}
271 </div>
272
273 {/* 分钟列 */}
274 <div
275 ref={minuteScrollRef}
276 className="flex-1 overflow-y-auto overscroll-contain scrollbar-thin scrollbar-thumb-muted-foreground/20 scrollbar-track-transparent hover:scrollbar-thumb-muted-foreground/40"
277 >
278 {Array.from({ length: 60 }, (_, i) => i).map((minute) => {
279 const isDisabled = disabledMinutes.includes(minute)
280 const isSelected = minute === selectedMinute
281 return (
282 <div
283 key={minute}
284 data-selected={isSelected}
285 onClick={() => !isDisabled && handleTimeChange(selectedHour, minute)}
286 className={cn(
287 'w-full text-center text-sm transition-colors tabular-nums cursor-pointer select-none',
288 'hover:bg-accent hover:text-accent-foreground',
289 isSelected && 'bg-primary/10 text-primary font-semibold ring-1 ring-primary/20',
290 isDisabled && 'opacity-30 cursor-not-allowed hover:bg-transparent',
291 isMobile ? 'py-3' : 'py-2',
292 )}
293 >
294 {minute.toString().padStart(2, '0')}
295 </div>
296 )
297 })}
298 </div>
299 </div>
300 </div>
301 </div>
302 )
303 }
304
304 lines Plain Text