| 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 |