返回 AiToEarn
1 /**
2 * MobileCalendar 组件
3 *
4 * 功能描述: 移动端日历主组件
5 * - 管理 selectedDate(当前选中日期)
6 * - 管理 viewType('week' | 'month')
7 * - 组合各子组件
8 * - 接收 onClickPub 回调,传递给子组件
9 */
10
11 'use client'
12
13 import type { TouchEvent } from 'react'
14 import type { IMobileCalendarProps, ViewType } from './mobileCalendar.types'
15 import dayjs from 'dayjs'
16 import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'
17 import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
18 import { useShallow } from 'zustand/react/shallow'
19 import { getMonthDateRange } from '@/app/[lng]/accounts/components/CalendarTiming/calendarTiming.utils'
20 import { useCalendarTiming } from '@/app/[lng]/accounts/components/CalendarTiming/useCalendarTiming'
21 import { useAccountStore } from '@/store/account'
22 import { cn } from '@/utils/className'
23 import MobileCalendarHeader from './MobileCalendarHeader'
24 import MobileDayRecords from './MobileDayRecords'
25 import MobileMonthView from './MobileMonthView'
26 import MobileWeekView from './MobileWeekView'
27
28 // 扩展 dayjs 插件
29 dayjs.extend(isSameOrAfter)
30
31 const WEEK_TRANSITION_DURATION = 300
32 const MONTH_TRANSITION_DURATION = 300
33 const VIEW_TRANSITION_DURATION = 340
34
35 const MobileCalendar = memo<IMobileCalendarProps>(({ onClickPub }) => {
36 // 当前显示的日期(用于控制月份/周显示)
37 const [currentDate, setCurrentDate] = useState<Date>(new Date())
38 // 选中的日期
39 const [selectedDate, setSelectedDate] = useState<Date>(new Date())
40 // 视图类型:周视图或月视图
41 const [viewType, setViewType] = useState<ViewType>('week')
42 const [weekTransition, setWeekTransition] = useState<{
43 direction: 'prev' | 'next'
44 fromDate: Date
45 fromSelectedDate: Date
46 toDate: Date
47 toSelectedDate: Date
48 } | null>(null)
49 const [weekTransitionActive, setWeekTransitionActive] = useState(false)
50 const [monthTransition, setMonthTransition] = useState<{
51 direction: 'prev' | 'next'
52 fromDate: Date
53 fromSelectedDate: Date
54 toDate: Date
55 toSelectedDate: Date
56 } | null>(null)
57 const [monthTransitionActive, setMonthTransitionActive] = useState(false)
58 const [viewTransition, setViewTransition] = useState<{
59 direction: 'down' | 'up'
60 fromViewType: ViewType
61 toViewType: ViewType
62 fromDate: Date
63 fromSelectedDate: Date
64 toDate: Date
65 toSelectedDate: Date
66 } | null>(null)
67 const [viewTransitionActive, setViewTransitionActive] = useState(false)
68 const [calendarViewHeight, setCalendarViewHeight] = useState<number | null>(null)
69 const touchStartRef = useRef<{ clientX: number, clientY: number } | null>(null)
70 const calendarContentRef = useRef<HTMLDivElement>(null)
71 const viewTransitionFromRef = useRef<HTMLDivElement>(null)
72 const viewTransitionToRef = useRef<HTMLDivElement>(null)
73 const viewTransitionFrameRef = useRef<number | null>(null)
74 const weekTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
75 const monthTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
76 const viewTransitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
77 const loadedRecordRangeRef = useRef<{
78 accountKey: string
79 start: number
80 end: number
81 } | null>(null)
82
83 const { activeAccountId, activeAccountType } = useAccountStore(
84 useShallow(state => ({
85 activeAccountId: state.accountActive?.id,
86 activeAccountType: state.accountActive?.type,
87 })),
88 )
89
90 const activeAccountCacheKey = useMemo(
91 () => `${activeAccountType ?? 'all'}:${activeAccountId ?? 'all'}`,
92 [activeAccountId, activeAccountType],
93 )
94
95 // 从 store 获取数据
96 const { recordMap, listLoading, getPubRecord, setCalendarRef } = useCalendarTiming(
97 useShallow(state => ({
98 recordMap: state.recordMap,
99 listLoading: state.listLoading,
100 getPubRecord: state.getPubRecord,
101 setCalendarRef: state.setCalendarRef,
102 })),
103 )
104
105 // 选中日期的任务列表
106 const selectedRecords = useMemo(() => {
107 const dateStr = dayjs(selectedDate).format('YYYY-MM-DD')
108 return recordMap.get(dateStr) || []
109 }, [selectedDate, recordMap])
110
111 // 移动端统一按月视图可见范围取数,避免周/月视图切换重复请求
112 useEffect(() => {
113 const [rangeStartDate, rangeEndDate] = getMonthDateRange(currentDate)
114 const nextRange = {
115 accountKey: activeAccountCacheKey,
116 start: dayjs(rangeStartDate).startOf('day').valueOf(),
117 end: dayjs(rangeEndDate).endOf('day').valueOf(),
118 }
119 const loadedRange = loadedRecordRangeRef.current
120
121 if (
122 loadedRange
123 && loadedRange.accountKey === nextRange.accountKey
124 && loadedRange.start <= nextRange.start
125 && loadedRange.end >= nextRange.end
126 ) {
127 return
128 }
129
130 loadedRecordRangeRef.current = nextRange
131 getPubRecord({ dateRange: [rangeStartDate, rangeEndDate] })
132 }, [activeAccountCacheKey, currentDate, getPubRecord])
133
134 useEffect(() => {
135 return () => {
136 if (weekTransitionTimerRef.current) {
137 clearTimeout(weekTransitionTimerRef.current)
138 }
139
140 if (monthTransitionTimerRef.current) {
141 clearTimeout(monthTransitionTimerRef.current)
142 }
143
144 if (viewTransitionTimerRef.current) {
145 clearTimeout(viewTransitionTimerRef.current)
146 }
147
148 if (viewTransitionFrameRef.current) {
149 cancelAnimationFrame(viewTransitionFrameRef.current)
150 }
151 }
152 }, [])
153
154 useLayoutEffect(() => {
155 if (!viewTransition) {
156 setCalendarViewHeight(null)
157 return undefined
158 }
159
160 const fromHeight = viewTransitionFromRef.current?.getBoundingClientRect().height
161 ?? calendarContentRef.current?.getBoundingClientRect().height
162 ?? 0
163 const toHeight = viewTransitionToRef.current?.getBoundingClientRect().height ?? fromHeight
164
165 setCalendarViewHeight(fromHeight)
166
167 if (viewTransitionFrameRef.current) {
168 cancelAnimationFrame(viewTransitionFrameRef.current)
169 }
170
171 viewTransitionFrameRef.current = requestAnimationFrame(() => {
172 setViewTransitionActive(true)
173 setCalendarViewHeight(toHeight)
174 viewTransitionFrameRef.current = null
175 })
176
177 return () => {
178 if (viewTransitionFrameRef.current) {
179 cancelAnimationFrame(viewTransitionFrameRef.current)
180 viewTransitionFrameRef.current = null
181 }
182 }
183 }, [viewTransition])
184
185 // 处理日期选择
186 const handleDateSelect = useCallback(
187 (date: Date) => {
188 if (weekTransition || monthTransition || viewTransition) {
189 return
190 }
191
192 setSelectedDate(date)
193 // 如果选择的日期不在当前显示的月份,则更新 currentDate
194 const newDate = dayjs(date)
195 const current = dayjs(currentDate)
196 if (!newDate.isSame(current, 'month')) {
197 setCurrentDate(date)
198 }
199 },
200 [currentDate, monthTransition, viewTransition, weekTransition],
201 )
202
203 const startViewTransition = useCallback(
204 (nextViewType: ViewType) => {
205 if (nextViewType === viewType || weekTransition || monthTransition || viewTransition) {
206 return
207 }
208
209 const direction = nextViewType === 'month' ? 'down' : 'up'
210 setCalendarViewHeight(calendarContentRef.current?.getBoundingClientRect().height ?? null)
211 setViewTransition({
212 direction,
213 fromViewType: viewType,
214 toViewType: nextViewType,
215 fromDate: currentDate,
216 fromSelectedDate: selectedDate,
217 toDate: currentDate,
218 toSelectedDate: selectedDate,
219 })
220 setViewTransitionActive(false)
221
222 if (viewTransitionTimerRef.current) {
223 clearTimeout(viewTransitionTimerRef.current)
224 }
225
226 viewTransitionTimerRef.current = setTimeout(() => {
227 setViewType(nextViewType)
228 setViewTransition(null)
229 setViewTransitionActive(false)
230 setCalendarViewHeight(null)
231 viewTransitionTimerRef.current = null
232 }, VIEW_TRANSITION_DURATION)
233 },
234 [currentDate, monthTransition, selectedDate, viewTransition, viewType, weekTransition],
235 )
236
237 // 处理周切换
238 const handleWeekChange = useCallback(
239 (direction: 'prev' | 'next') => {
240 if (viewType !== 'week' || weekTransition || monthTransition || viewTransition) {
241 return
242 }
243
244 const current = dayjs(currentDate)
245 const newDate = direction === 'next' ? current.add(1, 'week') : current.subtract(1, 'week')
246
247 // 同时更新选中日期到新周的同一天(周几)
248 const selectedDayOfWeek = dayjs(selectedDate).day()
249 const newSelected = newDate.startOf('week').add(selectedDayOfWeek, 'day')
250
251 setWeekTransition({
252 direction,
253 fromDate: currentDate,
254 fromSelectedDate: selectedDate,
255 toDate: newDate.toDate(),
256 toSelectedDate: newSelected.toDate(),
257 })
258 setWeekTransitionActive(false)
259
260 requestAnimationFrame(() => {
261 setWeekTransitionActive(true)
262 })
263
264 if (weekTransitionTimerRef.current) {
265 clearTimeout(weekTransitionTimerRef.current)
266 }
267
268 weekTransitionTimerRef.current = setTimeout(() => {
269 setCurrentDate(newDate.toDate())
270 setSelectedDate(newSelected.toDate())
271 setWeekTransition(null)
272 setWeekTransitionActive(false)
273 weekTransitionTimerRef.current = null
274 }, WEEK_TRANSITION_DURATION)
275 },
276 [currentDate, monthTransition, selectedDate, viewTransition, viewType, weekTransition],
277 )
278
279 // 处理月切换
280 const handleMonthChange = useCallback(
281 (direction: 'prev' | 'next') => {
282 if (viewType !== 'month' || weekTransition || monthTransition || viewTransition) {
283 return
284 }
285
286 const current = dayjs(currentDate)
287 const targetMonthStart = direction === 'next'
288 ? current.add(1, 'month').startOf('month')
289 : current.subtract(1, 'month').startOf('month')
290 const selectedDay = dayjs(selectedDate).date()
291 const targetDay = Math.min(selectedDay, targetMonthStart.daysInMonth())
292 const newSelected = targetMonthStart.date(targetDay)
293
294 setMonthTransition({
295 direction,
296 fromDate: currentDate,
297 fromSelectedDate: selectedDate,
298 toDate: newSelected.toDate(),
299 toSelectedDate: newSelected.toDate(),
300 })
301 setMonthTransitionActive(false)
302
303 requestAnimationFrame(() => {
304 setMonthTransitionActive(true)
305 })
306
307 if (monthTransitionTimerRef.current) {
308 clearTimeout(monthTransitionTimerRef.current)
309 }
310
311 monthTransitionTimerRef.current = setTimeout(() => {
312 setCurrentDate(newSelected.toDate())
313 setSelectedDate(newSelected.toDate())
314 setMonthTransition(null)
315 setMonthTransitionActive(false)
316 monthTransitionTimerRef.current = null
317 }, MONTH_TRANSITION_DURATION)
318 },
319 [currentDate, monthTransition, selectedDate, viewTransition, viewType, weekTransition],
320 )
321
322 // 处理年月切换(从选择器)
323 const handleDateChange = useCallback((date: Date) => {
324 setCurrentDate(date)
325 // 如果新月份中包含今天,则选中今天;否则选中月初
326 const newDate = dayjs(date)
327 const today = dayjs()
328 if (newDate.isSame(today, 'month')) {
329 setSelectedDate(today.toDate())
330 }
331 else {
332 setSelectedDate(newDate.startOf('month').toDate())
333 }
334 }, [])
335
336 // 处理点击今天
337 const handleToday = useCallback(() => {
338 const today = new Date()
339 setCurrentDate(today)
340 setSelectedDate(today)
341 }, [])
342
343 // 处理视图类型切换
344 const handleViewTypeChange = useCallback((type: ViewType) => {
345 startViewTransition(type)
346 }, [startViewTransition])
347
348 const handleCalendarTouchStart = useCallback((event: TouchEvent<HTMLDivElement>) => {
349 const touch = event.touches[0]
350 touchStartRef.current = {
351 clientX: touch.clientX,
352 clientY: touch.clientY,
353 }
354 }, [])
355
356 const handleCalendarTouchEnd = useCallback((event: TouchEvent<HTMLDivElement>) => {
357 const start = touchStartRef.current
358 touchStartRef.current = null
359
360 if (!start) {
361 return
362 }
363
364 const touch = event.changedTouches[0]
365 const deltaX = touch.clientX - start.clientX
366 const deltaY = touch.clientY - start.clientY
367 const absDeltaX = Math.abs(deltaX)
368 const absDeltaY = Math.abs(deltaY)
369 const threshold = 48
370
371 if (absDeltaX >= threshold && absDeltaX > absDeltaY) {
372 if (viewType === 'month') {
373 handleMonthChange(deltaX < 0 ? 'next' : 'prev')
374 }
375 return
376 }
377
378 if (absDeltaY < threshold || absDeltaY < absDeltaX) {
379 return
380 }
381
382 if (deltaY > 0 && viewType === 'week') {
383 startViewTransition('month')
384 return
385 }
386
387 if (deltaY < 0 && viewType === 'month') {
388 startViewTransition('week')
389 }
390 }, [handleMonthChange, startViewTransition, viewType])
391
392 const renderWeekView = useCallback(
393 (date: Date, selected: Date, interactive: boolean) => (
394 <MobileWeekView
395 currentDate={date}
396 selectedDate={selected}
397 recordMap={recordMap}
398 onDateSelect={interactive ? handleDateSelect : () => undefined}
399 onWeekChange={interactive ? handleWeekChange : () => undefined}
400 />
401 ),
402 [handleDateSelect, handleWeekChange, recordMap],
403 )
404
405 const renderViewByType = useCallback(
406 (type: ViewType, date: Date, selected: Date, interactive: boolean) => {
407 if (type === 'week') {
408 return renderWeekView(date, selected, interactive)
409 }
410
411 return (
412 <MobileMonthView
413 currentDate={date}
414 selectedDate={selected}
415 recordMap={recordMap}
416 onDateSelect={interactive ? handleDateSelect : () => undefined}
417 />
418 )
419 },
420 [handleDateSelect, recordMap, renderWeekView],
421 )
422
423 const renderCalendarView = () => {
424 if (viewTransition) {
425 const fromView = renderViewByType(
426 viewTransition.fromViewType,
427 viewTransition.fromDate,
428 viewTransition.fromSelectedDate,
429 false,
430 )
431 const toView = renderViewByType(
432 viewTransition.toViewType,
433 viewTransition.toDate,
434 viewTransition.toSelectedDate,
435 false,
436 )
437 const isDown = viewTransition.direction === 'down'
438
439 return (
440 <div
441 className="relative overflow-hidden motion-reduce:transition-none transition-[height] duration-[340ms] ease-[cubic-bezier(0.22,1,0.36,1)]"
442 style={calendarViewHeight !== null ? { height: calendarViewHeight } : undefined}
443 >
444 <div ref={viewTransitionFromRef} className="invisible pointer-events-none">{fromView}</div>
445 <div ref={viewTransitionToRef} className="invisible pointer-events-none absolute inset-x-0 top-0">{toView}</div>
446 <div
447 className={cn(
448 'absolute inset-x-0 top-0 will-change-transform motion-reduce:transition-none',
449 'transition-all duration-[340ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
450 viewTransitionActive
451 ? isDown ? 'translate-y-6 opacity-0 scale-[0.985]' : '-translate-y-6 opacity-0 scale-[0.985]'
452 : 'translate-y-0 opacity-100 scale-100',
453 )}
454 >
455 {fromView}
456 </div>
457 <div
458 className={cn(
459 'absolute inset-x-0 top-0 will-change-transform motion-reduce:transition-none',
460 'transition-all duration-[340ms] ease-[cubic-bezier(0.22,1,0.36,1)]',
461 viewTransitionActive
462 ? 'translate-y-0 opacity-100 scale-100'
463 : isDown ? '-translate-y-6 opacity-0 scale-[0.985]' : 'translate-y-6 opacity-0 scale-[0.985]',
464 )}
465 >
466 {toView}
467 </div>
468 </div>
469 )
470 }
471
472 if (viewType === 'week') {
473 if (!weekTransition) {
474 return renderWeekView(currentDate, selectedDate, true)
475 }
476
477 const isNext = weekTransition.direction === 'next'
478 const firstWeek = isNext
479 ? { date: weekTransition.fromDate, selected: weekTransition.fromSelectedDate }
480 : { date: weekTransition.toDate, selected: weekTransition.toSelectedDate }
481 const secondWeek = isNext
482 ? { date: weekTransition.toDate, selected: weekTransition.toSelectedDate }
483 : { date: weekTransition.fromDate, selected: weekTransition.fromSelectedDate }
484
485 return (
486 <div className="overflow-hidden">
487 <div
488 className={cn(
489 'flex w-[200%] will-change-transform motion-reduce:transition-none',
490 'transition-transform duration-300 ease-out',
491 weekTransitionActive
492 ? isNext ? '-translate-x-1/2' : 'translate-x-0'
493 : isNext ? 'translate-x-0' : '-translate-x-1/2',
494 )}
495 >
496 <div className="w-1/2 shrink-0 pointer-events-none">
497 {renderWeekView(firstWeek.date, firstWeek.selected, false)}
498 </div>
499 <div className="w-1/2 shrink-0 pointer-events-none">
500 {renderWeekView(secondWeek.date, secondWeek.selected, false)}
501 </div>
502 </div>
503 </div>
504 )
505 }
506
507 if (monthTransition) {
508 const isNext = monthTransition.direction === 'next'
509 const firstMonth = isNext
510 ? { date: monthTransition.fromDate, selected: monthTransition.fromSelectedDate }
511 : { date: monthTransition.toDate, selected: monthTransition.toSelectedDate }
512 const secondMonth = isNext
513 ? { date: monthTransition.toDate, selected: monthTransition.toSelectedDate }
514 : { date: monthTransition.fromDate, selected: monthTransition.fromSelectedDate }
515
516 return (
517 <div className="overflow-hidden">
518 <div
519 className={cn(
520 'flex w-[200%] will-change-transform motion-reduce:transition-none',
521 'transition-transform duration-300 ease-out',
522 monthTransitionActive
523 ? isNext ? '-translate-x-1/2' : 'translate-x-0'
524 : isNext ? 'translate-x-0' : '-translate-x-1/2',
525 )}
526 >
527 <div className="w-1/2 shrink-0 pointer-events-none">
528 {renderViewByType('month', firstMonth.date, firstMonth.selected, false)}
529 </div>
530 <div className="w-1/2 shrink-0 pointer-events-none">
531 {renderViewByType('month', secondMonth.date, secondMonth.selected, false)}
532 </div>
533 </div>
534 </div>
535 )
536 }
537
538 return renderViewByType('month', currentDate, selectedDate, true)
539 }
540
541 return (
542 <div data-testid="mobile-calendar-container" className="flex flex-col flex-1 overflow-hidden bg-background">
543 {/* 顶部工具栏 */}
544 <MobileCalendarHeader
545 currentDate={currentDate}
546 viewType={viewTransition?.toViewType ?? viewType}
547 onDateChange={handleDateChange}
548 onViewTypeChange={handleViewTypeChange}
549 onToday={handleToday}
550 />
551
552 {/* 日历视图 */}
553 <div
554 ref={calendarContentRef}
555 className="overflow-hidden"
556 onTouchStart={handleCalendarTouchStart}
557 onTouchEnd={handleCalendarTouchEnd}
558 >
559 <div>
560 {renderCalendarView()}
561 </div>
562 </div>
563
564 {/* 分隔线 */}
565 <div className="border-b" />
566
567 {/* 任务列表 */}
568 <MobileDayRecords
569 selectedDate={selectedDate}
570 records={selectedRecords}
571 loading={listLoading}
572 onClickPub={onClickPub}
573 />
574 </div>
575 )
576 })
577
578 MobileCalendar.displayName = 'MobileCalendar'
579
580 export default MobileCalendar
581
581 lines Plain Text