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