返回 AiToEarn
1 /**
2 * PCWeekView 组件
3 *
4 * 功能描述: PC 端日历周视图
5 * - 显示当前周的 7 天(周日 - 周六)
6 * - Y 轴显示时间刻度(每 2 小时)
7 * - 网格布局:时间行 × 日期列
8 * - 支持拖拽任务到不同日期/时间
9 */
10
11 'use client'
12
13 import type { Dayjs } from 'dayjs'
14 import type { CalendarFestivalInfo } from '../calendarFestival.utils'
15 import type { PublishRecordItem } from '@/api/platforms/publish.types'
16 import dayjs from 'dayjs'
17 import { ChevronDown, ChevronUp, Plus } from 'lucide-react'
18 import { memo, useEffect, useMemo, useRef, useState } from 'react'
19 import { DndProvider, useDrop } from 'react-dnd'
20 import { HTML5Backend } from 'react-dnd-html5-backend'
21 import { useShallow } from 'zustand/react/shallow'
22 import { useTransClient } from '@/app/i18n/client'
23 import { getDayjsLocale } from '@/app/i18n/languageConfig'
24 import { Button } from '@/components/ui/button'
25 import { Skeleton } from '@/components/ui/skeleton'
26 import { useGetClientLng } from '@/hooks/useSystem'
27 import { useSystemStore } from '@/store/system'
28 import { cn } from '@/utils/className'
29 import CalendarRecord from '../../CalendarTimingItem/components/CalendarRecord'
30 import { CustomDragLayer } from '../../CalendarTimingItem/components/CustomDragLayer'
31 import {
32 filterCalendarFestivalEvents,
33 getChinaCalendarEvents,
34 getChinaCalendarLunarInfo,
35 } from '../calendarFestival.utils'
36 import CalendarFestivalSummary from '../CalendarFestivalSummary'
37 import CalendarLunarText from '../CalendarLunarText'
38 import 'dayjs/locale/zh-cn'
39 import 'dayjs/locale/en'
40 import 'dayjs/locale/de'
41 import 'dayjs/locale/fr'
42 import 'dayjs/locale/ja'
43 import 'dayjs/locale/ko'
44
45 // 时间槽:每 2 小时(12 AM, 2 AM, 4 AM, ..., 10 PM)
46 const TIME_SLOTS = [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22]
47 const HOURS_PER_TIME_SLOT = 2
48 const VISIBLE_HOURS_PER_SCREEN = 12
49 const VISIBLE_TIME_SLOT_COUNT = VISIBLE_HOURS_PER_SCREEN / HOURS_PER_TIME_SLOT
50 const DEFAULT_TIME_CELL_HEIGHT = 120
51
52 /**
53 * 格式化小时为 24 小时制
54 */
55 function formatHour(hour: number): string {
56 return `${hour.toString().padStart(2, '0')}:00`
57 }
58
59 /**
60 * 获取发布时间对应的时间槽(每 2 小时一个槽)
61 */
62 function getTimeSlot(publishTime: string | Date): number {
63 const hour = dayjs(publishTime).hour()
64 return Math.floor(hour / 2) * 2
65 }
66
67 function getFestivalStatusTone(festival: CalendarFestivalInfo) {
68 if (festival.isWorkday) {
69 return 'border border-border bg-background text-muted-foreground'
70 }
71
72 return 'bg-gradient-back text-gradient-foreground shadow-primary/15'
73 }
74
75 export interface IPCWeekViewProps {
76 /** 当前周的基准日期 */
77 currentDate: Date
78 /** 发布记录数据 */
79 recordMap: Map<string, PublishRecordItem[]>
80 /** 加载状态 */
81 loading: boolean
82 /** 点击添加任务时的回调 */
83 onClickPub: (date: string) => void
84 }
85
86 // 时间单元格组件
87 interface ITimeCellProps {
88 date: Dayjs
89 hour: number
90 records: PublishRecordItem[]
91 loading: boolean
92 isPast: boolean
93 isToday: boolean
94 onClickPub: (date: string) => void
95 }
96
97 const MAX_RECORDS = 3
98
99 const TimeCell = memo<ITimeCellProps>(
100 ({ date, hour, records, loading, isPast, isToday, onClickPub }) => {
101 const { t } = useTransClient('account')
102 const [isMore, setIsMore] = useState(false)
103
104 const displayRecords = useMemo(() => {
105 if (isMore)
106 return records
107 return records.slice(0, MAX_RECORDS)
108 }, [isMore, records])
109
110 // 拖放支持
111 const [{ isOver }, drop] = useDrop(
112 () => ({
113 accept: isPast ? 'none' : 'box',
114 drop: () => ({
115 time: {
116 date: date.hour(hour).minute(0).second(0).toDate(),
117 },
118 }),
119 collect: monitor => ({
120 isOver: monitor.isOver(),
121 }),
122 }),
123 [isPast, date, hour],
124 )
125
126 // 处理添加按钮点击
127 const handleAddClick = () => {
128 const now = dayjs()
129 if (date.isSame(now, 'day') && hour === getTimeSlot(now.toDate())) {
130 // 当前时间槽:使用当前时间 + 10 分钟
131 onClickPub(now.add(10, 'minute').format())
132 }
133 else {
134 // 其他时间槽:使用时间槽的开始时间
135 onClickPub(date.hour(hour).minute(0).second(0).format())
136 }
137 }
138
139 return (
140 <div
141 ref={(node) => {
142 if (!isPast) {
143 drop(node)
144 }
145 }}
146 className={cn(
147 'group relative flex-1 min-w-0 overflow-visible border-r border-b last:border-r-0 p-2',
148 'transition-colors',
149 isPast && 'bg-muted/30',
150 isOver && !isPast && 'bg-accent/50',
151 isToday && !isPast && 'bg-primary/5',
152 !isPast && 'hover:bg-muted/10',
153 )}
154 >
155 {loading ? (
156 <Skeleton className="h-8 w-full rounded-md" />
157 ) : (
158 <>
159 {/* 添加按钮 - 右上角,悬停显示 */}
160 {!isPast && (
161 <Button
162 variant="ghost"
163 size="icon"
164 className={cn(
165 'absolute top-1 right-1',
166 'h-6 w-6',
167 'opacity-0 group-hover:opacity-100',
168 'cursor-pointer transition-opacity',
169 )}
170 onClick={handleAddClick}
171 >
172 <Plus className="h-3.5 w-3.5" />
173 </Button>
174 )}
175 {/* 任务列表 */}
176 <div className="space-y-1.5">
177 {displayRecords.map(record => (
178 <div key={record.id + record.title + record.uid + record.updatedAt}>
179 <CustomDragLayer publishRecord={record} snapToGrid={false} />
180 <CalendarRecord publishRecord={record} />
181 </div>
182 ))}
183
184 {/* 显示更多/收起按钮 */}
185 {records.length > MAX_RECORDS && (
186 <Button
187 data-testid="calendar-week-cell-show-more"
188 variant="ghost"
189 className="w-full h-auto py-2 px-3 text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors justify-start cursor-pointer"
190 onClick={() => setIsMore(!isMore)}
191 >
192 {isMore ? (
193 <>
194 <ChevronUp className="mr-2 h-4 w-4" />
195 {t('calendar.hideMore')}
196 </>
197 ) : (
198 <>
199 <ChevronDown className="mr-2 h-4 w-4" />
200 {records.length - displayRecords.length}
201 {' '}
202 {t('calendar.showMore')}
203 </>
204 )}
205 </Button>
206 )}
207 </div>
208 </>
209 )}
210 </div>
211 )
212 },
213 )
214
215 TimeCell.displayName = 'TimeCell'
216
217 const PCWeekView = memo<IPCWeekViewProps>(({ currentDate, recordMap, loading, onClickPub }) => {
218 const { t } = useTransClient('account')
219 const lng = useGetClientLng()
220 const scrollContainerRef = useRef<HTMLDivElement>(null)
221 const weekHeaderRef = useRef<HTMLDivElement>(null)
222 const [timeCellHeight, setTimeCellHeight] = useState(DEFAULT_TIME_CELL_HEIGHT)
223 const { showSolarFestivals, showSolarTerms } = useSystemStore(
224 useShallow(state => ({
225 showSolarFestivals: state.calendarShowSolarFestivals,
226 showSolarTerms: state.calendarShowSolarTerms,
227 })),
228 )
229
230 // 获取当前周的日期(周日开始)
231 const weekDates = useMemo(() => {
232 const start = dayjs(currentDate).startOf('week')
233 return Array.from({ length: 7 }, (_, i) => start.add(i, 'day'))
234 }, [currentDate])
235
236 // 今天的日期
237 const today = dayjs()
238 const todayStr = today.format('YYYY-MM-DD')
239 const currentHour = today.hour()
240
241 // 计算当前时间的时间槽索引(用于自动滚动)
242 const currentTimeSlotIndex = useMemo(() => {
243 return Math.floor(currentHour / 2)
244 }, [currentHour])
245
246 useEffect(() => {
247 const updateTimeCellHeight = () => {
248 const containerHeight = scrollContainerRef.current?.clientHeight ?? 0
249 const headerHeight = weekHeaderRef.current?.offsetHeight ?? 0
250 const availableHeight = containerHeight - headerHeight
251
252 if (availableHeight <= 0) {
253 return
254 }
255
256 const nextHeight = Math.min(
257 DEFAULT_TIME_CELL_HEIGHT,
258 Math.max(1, Math.floor(availableHeight / VISIBLE_TIME_SLOT_COUNT)),
259 )
260
261 setTimeCellHeight(prev => (prev === nextHeight ? prev : nextHeight))
262 }
263
264 updateTimeCellHeight()
265
266 const resizeObserver = typeof ResizeObserver === 'undefined'
267 ? null
268 : new ResizeObserver(updateTimeCellHeight)
269
270 if (scrollContainerRef.current) {
271 resizeObserver?.observe(scrollContainerRef.current)
272 }
273
274 if (weekHeaderRef.current) {
275 resizeObserver?.observe(weekHeaderRef.current)
276 }
277
278 window.addEventListener('resize', updateTimeCellHeight)
279
280 return () => {
281 resizeObserver?.disconnect()
282 window.removeEventListener('resize', updateTimeCellHeight)
283 }
284 }, [])
285
286 // 组件挂载时滚动到当前时间
287 useEffect(() => {
288 if (scrollContainerRef.current) {
289 const headerHeight = weekHeaderRef.current?.offsetHeight ?? 80
290 const scrollOffset = currentTimeSlotIndex * timeCellHeight
291
292 // 滚动使当前时间槽在视口中间偏上位置
293 scrollContainerRef.current.scrollTop = Math.max(0, scrollOffset - headerHeight)
294 }
295 }, [currentTimeSlotIndex, timeCellHeight])
296
297 // 按日期+时间槽分组任务
298 const groupedRecords = useMemo(() => {
299 const map = new Map<string, PublishRecordItem[]>()
300 recordMap.forEach((records, dateStr) => {
301 records.forEach((record) => {
302 const slot = getTimeSlot(record.publishTime)
303 const key = `${dateStr}-${slot}`
304 if (!map.has(key)) {
305 map.set(key, [])
306 }
307 map.get(key)!.push(record)
308 })
309 })
310 return map
311 }, [recordMap])
312
313 // 格式化星期名称
314 const formatWeekday = useMemo(() => {
315 const locale = getDayjsLocale(lng)
316 return (date: Dayjs) => {
317 return date.locale(locale).format('dddd')
318 }
319 }, [lng])
320
321 return (
322 <DndProvider backend={HTML5Backend}>
323 <div data-testid="calendar-week-view" className="flex flex-col h-full overflow-hidden">
324 {/* 单一滚动容器:表头和内容共享,解决滚动条导致的边框错位 */}
325 <div ref={scrollContainerRef} className="flex-1 overflow-auto">
326 {/* 表头行:sticky 固定在滚动容器顶部 */}
327 <div ref={weekHeaderRef} className="flex sticky top-0 z-20 bg-background">
328 {/* 时间轴占位 */}
329 <div className="w-16 shrink-0 border-r border-b" />
330 {/* 7 天表头 */}
331 {weekDates.map((date) => {
332 const dateStr = date.format('YYYY-MM-DD')
333 const isToday = dateStr === todayStr
334 const festivals = filterCalendarFestivalEvents(getChinaCalendarEvents(date.toDate(), lng), {
335 showSolarFestivals,
336 showSolarTerms,
337 })
338 const lunar = getChinaCalendarLunarInfo(date.toDate(), lng)
339 const legalFestival = festivals.find(item => item.type === 'holiday' || item.type === 'workday')
340
341 return (
342 <div
343 key={dateStr}
344 className={cn(
345 'flex-1 min-w-0 border-r border-b last:border-r-0 px-2 py-3 transition-colors',
346 isToday && 'bg-gradient-to-b from-primary/10 to-transparent',
347 )}
348 >
349 <div className="mx-auto flex w-full flex-col items-center gap-1.5">
350 <CalendarFestivalSummary
351 festivals={festivals}
352 date={date.toDate()}
353 lunar={lunar}
354 headerClassName="mx-auto flex w-fit max-w-full flex-col items-center gap-1 rounded-lg px-1.5 py-1"
355 headerContent={(
356 <>
357 <span
358 className={cn(
359 'text-sm font-medium text-muted-foreground',
360 isToday && 'text-primary',
361 )}
362 >
363 {formatWeekday(date)}
364 </span>
365 <span className="flex items-center justify-center gap-2">
366 <span
367 className={cn(
368 'flex size-8 items-center justify-center rounded-full text-xl font-bold tabular-nums text-foreground',
369 isToday
370 && 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/25 ring-1 ring-primary/20',
371 )}
372 >
373 {date.date()}
374 </span>
375 <CalendarLunarText lunar={lunar} selected={isToday} className={cn(isToday && 'text-primary')} />
376 {legalFestival && (
377 <span
378 className={cn(
379 'inline-flex h-4 min-w-4 shrink-0 items-center justify-center rounded-full px-1 text-[10px] font-bold leading-none shadow-sm',
380 getFestivalStatusTone(legalFestival),
381 )}
382 title={t(legalFestival.statusTitleKey)}
383 aria-label={t(legalFestival.statusTitleKey)}
384 >
385 {t(legalFestival.statusKey)}
386 </span>
387 )}
388 </span>
389 </>
390 )}
391 summaryCompact={false}
392 summaryClassName="max-h-none overflow-visible px-0 text-center text-[11px] leading-4 whitespace-normal break-words"
393 popoverSide="bottom"
394 popoverAlign="center"
395 className="w-full overflow-visible"
396 />
397 </div>
398 </div>
399 )
400 })}
401 </div>
402
403 {/* 时间行 */}
404 {TIME_SLOTS.map((hour) => {
405 return (
406 <div key={hour} className="flex" style={{ minHeight: timeCellHeight }}>
407 {/* 时间标签 */}
408 <div className="w-16 shrink-0 border-r border-b px-2 py-2 text-xs text-muted-foreground text-right pr-3">
409 {formatHour(hour)}
410 </div>
411 {/* 7 天的单元格 */}
412 {weekDates.map((date) => {
413 const dateStr = date.format('YYYY-MM-DD')
414 const isToday = dateStr === todayStr
415 const isPastDay = date.isBefore(today, 'day')
416 // 今天的过去小时也算过去
417 const isPastHour = isToday && hour + 2 <= currentHour
418 const isPast = isPastDay || isPastHour
419 const key = `${dateStr}-${hour}`
420 const records = groupedRecords.get(key) || []
421
422 return (
423 <TimeCell
424 key={key}
425 date={date}
426 hour={hour}
427 records={records}
428 loading={loading}
429 isPast={isPast}
430 isToday={isToday}
431 onClickPub={onClickPub}
432 />
433 )
434 })}
435 </div>
436 )
437 })}
438 </div>
439 </div>
440 </DndProvider>
441 )
442 })
443
444 PCWeekView.displayName = 'PCWeekView'
445
446 export default PCWeekView
447
447 lines Plain Text