返回 AiToEarn
index.tsx
1 /**
2 * CalendarTiming 组件
3 *
4 * 功能描述: 日历定时发布组件 - 显示日历视图,管理发布任务
5 * - PC端:支持周视图/月视图切换,使用 FullCalendar(月视图)或自定义组件(周视图)
6 * - 移动端:使用自定义周视图/月视图 + 任务列表
7 */
8
9 import type { DatesSetArg } from '@fullcalendar/core'
10 import type { ForwardedRef } from 'react'
11 import type { IPublishDialogRef } from '@/components/PublishDialog'
12 import type { CalendarViewType } from '@/store/system'
13 import dayGridPlugin from '@fullcalendar/daygrid'
14 import FullCalendar from '@fullcalendar/react'
15 import dayjs from 'dayjs'
16 import { useSearchParams } from 'next/navigation'
17 import { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'
18 import { DndProvider } from 'react-dnd'
19 import { HTML5Backend } from 'react-dnd-html5-backend'
20 import { CSSTransition } from 'react-transition-group'
21 import { useShallow } from 'zustand/react/shallow'
22 import {
23 getDays,
24 getFullCalendarLang,
25 getMonthDateRange,
26 getTransitionClassNames,
27 getWeekDateRange,
28 } from '@/app/[lng]/accounts/components/CalendarTiming/calendarTiming.utils'
29 import CalendarToolbar from '@/app/[lng]/accounts/components/CalendarTiming/CalendarToolbar'
30 import { useCalendarTiming } from '@/app/[lng]/accounts/components/CalendarTiming/useCalendarTiming'
31 import { useNewWork } from '@/app/[lng]/accounts/hooks/useNewWork'
32 import PublishDialog from '@/components/PublishDialog'
33 import { Skeleton } from '@/components/ui/skeleton'
34 import { useIsMobile } from '@/hooks/useIsMobile'
35 import { useGetClientLng } from '@/hooks/useSystem'
36 import { useAccountStore } from '@/store/account'
37 import { useSystemStore } from '@/store/system'
38 import CalendarTimingItem from '../CalendarTimingItem'
39 import MobileCalendar from './MobileCalendar'
40 import PCWeekView from './PCWeekView'
41 import './calendarTiming.scss'
42
43 export interface ICalendarTimingRef {}
44 export interface ICalendarTimingProps {}
45
46 const CalendarTiming = memo(
47 forwardRef(({}: ICalendarTimingProps, ref: ForwardedRef<ICalendarTimingRef>) => {
48 const lng = useGetClientLng()
49 const isMobile = useIsMobile()
50 const searchParams = useSearchParams()
51 const calendarRef = useRef<FullCalendar | null>(null)
52 const [animating, setAnimating] = useState(false)
53 // 方向:'left'(下月/下周)、'right'(上月/上周)、'fade' (今天)
54 const [direction, setDirection] = useState<'left' | 'right' | 'fade'>('left')
55 const [currentDate, setCurrentDate] = useState<Date>(new Date())
56
57 // 从持久化 store 获取视图类型
58 const { calendarViewType, setCalendarViewType, _hasHydrated } = useSystemStore(
59 useShallow(state => ({
60 calendarViewType: state.calendarViewType,
61 setCalendarViewType: state.setCalendarViewType,
62 _hasHydrated: state._hasHydrated,
63 })),
64 )
65
66 const handleDatesSet = (arg: DatesSetArg) => {
67 const date = calendarRef.current?.getApi().getDate()
68 if (date) {
69 setCurrentDate(date)
70 }
71 }
72 const calendarTimingCalendarRef = useRef<HTMLDivElement>(null)
73 const [publishDialogOpen, setPublishDialogOpen] = useState(false)
74 const [defaultAccountIds, setDefaultAccountIds] = useState<string[]>()
75 const { accountList, accountActive, accountLoading, accountListInitialized } = useAccountStore(
76 useShallow(state => ({
77 accountList: state.accountList,
78 accountActive: state.accountActive,
79 accountLoading: state.accountLoading,
80 accountListInitialized: state.accountListInitialized,
81 })),
82 )
83 const accountListInitialLoading = accountLoading && !accountListInitialized
84
85 // 使用新建作品 hook
86 const publishDialogRef = useRef<IPublishDialogRef>(null)
87 const { openNewWork } = useNewWork({
88 publishDialogRef,
89 setPublishDialogOpen,
90 setDefaultAccountIds,
91 })
92
93 const { setCalendarCallWidth, listLoading, recordMap, setCalendarRef, getPubRecord }
94 = useCalendarTiming(
95 useShallow(state => ({
96 setCalendarCallWidth: state.setCalendarCallWidth,
97 listLoading: state.listLoading,
98 recordMap: state.recordMap,
99 getPubRecord: state.getPubRecord,
100 setCalendarRef: state.setCalendarRef,
101 })),
102 )
103
104 useEffect(() => {
105 if (isMobile || window.matchMedia('(max-width: 767px)').matches) {
106 return undefined
107 }
108
109 setCalendarRef(calendarRef.current!)
110 window.addEventListener('resize', handleResize)
111
112 setTimeout(() => {
113 handleResize()
114 }, 1)
115
116 // 清理事件监听
117 return () => window.removeEventListener('resize', handleResize)
118 }, [isMobile])
119
120 // 监听 URL 参数,自动打开发布弹窗
121 useEffect(() => {
122 const openPublish = searchParams.get('openPublish')
123 const fromSignIn = searchParams.get('fromSignIn')
124
125 if (openPublish === 'true' && fromSignIn === 'true') {
126 setPublishDialogOpen(true)
127 // 清除 URL 参数,避免刷新页面时重复打开
128 const url = new URL(window.location.href)
129 url.searchParams.delete('openPublish')
130 url.searchParams.delete('fromSignIn')
131 window.history.replaceState({}, '', url.toString())
132 }
133 }, [searchParams])
134
135 // 监听自定义事件,打开发布弹窗
136 useEffect(() => {
137 const handleOpenPublishDialog = (event: CustomEvent) => {
138 if (event.detail?.fromSignIn) {
139 setPublishDialogOpen(true)
140 }
141 }
142
143 window.addEventListener('openPublishDialog', handleOpenPublishDialog as EventListener)
144
145 return () => {
146 window.removeEventListener('openPublishDialog', handleOpenPublishDialog as EventListener)
147 }
148 }, [])
149
150 useEffect(() => {
151 if (isMobile || window.matchMedia('(max-width: 767px)').matches) {
152 return
153 }
154
155 // 账号切换或视图类型切换时重新获取数据
156 // 注意:不依赖 currentDate,因为日期变化在导航函数中已处理
157 if (calendarViewType === 'week') {
158 getPubRecord({ dateRange: getWeekDateRange(currentDate) })
159 }
160 else {
161 getPubRecord({ dateRange: getMonthDateRange(currentDate) })
162 }
163 }, [accountActive, calendarViewType, isMobile])
164
165 // 处理窗口大小变化
166 const handleResize = () => {
167 setTimeout(() => {
168 const el = document.querySelector('.calendarTimingItem--js')
169 if (!el)
170 return
171
172 const style = window.getComputedStyle(el)
173 const paddingLeft = Number.parseFloat(style.paddingLeft)
174 const paddingRight = Number.parseFloat(style.paddingRight)
175
176 setCalendarCallWidth(el.clientWidth - (paddingLeft + paddingRight))
177 }, 100)
178 }
179
180 // 动画触发函数
181 const triggerAnimation = (dir: 'left' | 'right' | 'fade') => {
182 if (calendarTimingCalendarRef.current) {
183 calendarTimingCalendarRef.current.scrollTop = 0
184 }
185 setDirection(dir)
186 setAnimating(true)
187 }
188
189 // 点击上/下月(或上/下周)按钮时
190 const handlePrev = () => {
191 triggerAnimation('right')
192 setTimeout(() => {
193 if (calendarViewType === 'month') {
194 calendarRef.current?.getApi().prev()
195 const newDate = dayjs(currentDate).subtract(1, 'month').toDate()
196 setCurrentDate(newDate)
197 getPubRecord({ dateRange: getMonthDateRange(newDate) })
198 }
199 else {
200 // 周视图:前一周
201 const newDate = dayjs(currentDate).subtract(1, 'week').toDate()
202 setCurrentDate(newDate)
203 getPubRecord({ dateRange: getWeekDateRange(newDate) })
204 }
205 setAnimating(false)
206 }, 300)
207 }
208
209 const handleNext = () => {
210 triggerAnimation('left')
211 setTimeout(() => {
212 if (calendarViewType === 'month') {
213 calendarRef.current?.getApi().next()
214 const newDate = dayjs(currentDate).add(1, 'month').toDate()
215 setCurrentDate(newDate)
216 getPubRecord({ dateRange: getMonthDateRange(newDate) })
217 }
218 else {
219 // 周视图:后一周
220 const newDate = dayjs(currentDate).add(1, 'week').toDate()
221 setCurrentDate(newDate)
222 getPubRecord({ dateRange: getWeekDateRange(newDate) })
223 }
224 setAnimating(false)
225 }, 300)
226 }
227
228 // 点击Today按钮时
229 const handleToday = () => {
230 triggerAnimation('fade')
231 setTimeout(() => {
232 const today = new Date()
233 if (calendarViewType === 'month') {
234 calendarRef.current?.getApi().today()
235 setCurrentDate(today)
236 getPubRecord({ dateRange: getMonthDateRange(today) })
237 }
238 else {
239 // 周视图:回到今天所在的周
240 setCurrentDate(today)
241 getPubRecord({ dateRange: getWeekDateRange(today) })
242 }
243 setAnimating(false)
244 }, 300)
245 }
246
247 // 视图类型切换
248 const handleViewTypeChange = useCallback(
249 (type: CalendarViewType) => {
250 setCalendarViewType(type)
251 if (type === 'month') {
252 // 切换到月视图时,需要重新计算日历单元格宽度
253 // 延迟执行,等待 DOM 渲染完成
254 setTimeout(() => {
255 handleResize()
256 }, 150)
257 }
258 },
259 [setCalendarViewType, handleResize],
260 )
261
262 // FullCalendar 内容(移动端不包裹 DndProvider)
263 const calendarContent = (
264 <FullCalendar
265 ref={calendarRef}
266 locale={getFullCalendarLang(lng)}
267 plugins={[dayGridPlugin]}
268 initialView="dayGridMonth"
269 initialDate={currentDate}
270 headerToolbar={false}
271 stickyFooterScrollbar={true}
272 dayCellContent={(arg) => {
273 const dateStr = getDays(arg.date).format('YYYY-MM-DD')
274 return (
275 <CalendarTimingItem
276 key={dateStr}
277 loading={listLoading}
278 arg={arg}
279 onClickPub={date => openNewWork({ date })}
280 />
281 )
282 }}
283 datesSet={handleDatesSet}
284 />
285 )
286
287 return (
288 <div data-testid="calendar-container" className="flex flex-col flex-1 overflow-hidden">
289 <PublishDialog
290 defaultAccountIds={defaultAccountIds}
291 ref={publishDialogRef}
292 open={publishDialogOpen}
293 onClose={() => {
294 setPublishDialogOpen(false)
295 setDefaultAccountIds(undefined)
296 }}
297 onPubSuccess={() => {
298 getPubRecord({
299 dateRange: calendarViewType === 'week'
300 ? getWeekDateRange(currentDate)
301 : getMonthDateRange(currentDate),
302 })
303 }}
304 accounts={accountList}
305 accountListInitialLoading={accountListInitialLoading}
306 />
307
308 {/* 移动端:使用自定义日历组件 */}
309 {isMobile ? (
310 <MobileCalendar onClickPub={date => openNewWork({ date })} />
311 ) : (
312 <>
313 {/* PC端:等待持久化数据加载完成 */}
314 {!_hasHydrated ? (
315 <div className="flex flex-col flex-1 p-4 space-y-4">
316 <Skeleton className="h-14 w-full" />
317 <div className="flex-1 grid grid-cols-7 gap-2">
318 {Array.from({ length: 7 }).map((_, i) => (
319 <Skeleton key={i} className="h-full min-h-[400px]" />
320 ))}
321 </div>
322 </div>
323 ) : (
324 <>
325 {/* PC端:日历工具栏 */}
326 <CalendarToolbar
327 currentDate={currentDate}
328 viewType={calendarViewType}
329 onPrev={handlePrev}
330 onNext={handleNext}
331 onToday={handleToday}
332 onViewTypeChange={handleViewTypeChange}
333 />
334
335 {/* PC端:主内容区域 - 日历视图 */}
336 <div className="flex-1 overflow-hidden relative">
337 <CSSTransition
338 in={!animating}
339 timeout={300}
340 classNames={getTransitionClassNames(direction)}
341 unmountOnExit
342 >
343 {calendarViewType === 'month' ? (
344 <div
345 data-testid="calendar-month-view"
346 className="calendarTiming-calendar overflow-hidden"
347 id="calendarTiming-calendar"
348 ref={calendarTimingCalendarRef}
349 >
350 <DndProvider backend={HTML5Backend}>{calendarContent}</DndProvider>
351 </div>
352 ) : (
353 <PCWeekView
354 currentDate={currentDate}
355 recordMap={recordMap}
356 loading={listLoading}
357 onClickPub={date => openNewWork({ date })}
358 />
359 )}
360 </CSSTransition>
361 </div>
362 </>
363 )}
364 </>
365 )}
366 </div>
367 )
368 }),
369 )
370
371 export default CalendarTiming
372
372 lines Plain Text