| 1 | /** |
| 2 | * CalendarTimingItem 组件 |
| 3 | * |
| 4 | * 功能描述: 日历单元格组件 - 显示日期和发布记录 |
| 5 | */ |
| 6 | |
| 7 | import type { DayCellContentArg } from '@fullcalendar/core' |
| 8 | import type { ForwardedRef } from 'react' |
| 9 | import type { PublishRecordItem } from '@/api/platforms/publish.types' |
| 10 | import dayjs from 'dayjs' |
| 11 | import { ChevronDown, ChevronUp, Plus } from 'lucide-react' |
| 12 | import { forwardRef, memo, useEffect, useMemo, useRef, useState } from 'react' |
| 13 | import { useDrop } from 'react-dnd' |
| 14 | import { useShallow } from 'zustand/react/shallow' |
| 15 | import { useCalendarTiming } from '@/app/[lng]/accounts/components/CalendarTiming/useCalendarTiming' |
| 16 | import { useTransClient } from '@/app/i18n/client' |
| 17 | import { Button } from '@/components/ui/button' |
| 18 | import { Skeleton } from '@/components/ui/skeleton' |
| 19 | import { useIsMobile } from '@/hooks/useIsMobile' |
| 20 | import { useGetClientLng } from '@/hooks/useSystem' |
| 21 | import { useSystemStore } from '@/store/system' |
| 22 | import { cn } from '@/utils/className' |
| 23 | import { |
| 24 | filterCalendarFestivalEvents, |
| 25 | getChinaCalendarEvents, |
| 26 | getChinaCalendarLunarInfo, |
| 27 | } from '../CalendarTiming/calendarFestival.utils' |
| 28 | import CalendarFestivalSummary from '../CalendarTiming/CalendarFestivalSummary' |
| 29 | import CalendarLunarText from '../CalendarTiming/CalendarLunarText' |
| 30 | import CalendarRecord from './components/CalendarRecord' |
| 31 | import { CustomDragLayer } from './components/CustomDragLayer' |
| 32 | |
| 33 | export interface ICalendarTimingItemRef {} |
| 34 | |
| 35 | export interface ICalendarTimingItemProps { |
| 36 | arg: DayCellContentArg |
| 37 | onClickPub: (date: string) => void |
| 38 | loading: boolean |
| 39 | } |
| 40 | |
| 41 | const EMPTY_RECORDS: PublishRecordItem[] = [] |
| 42 | |
| 43 | const CalendarTimingItem = memo( |
| 44 | forwardRef( |
| 45 | ( |
| 46 | { arg, onClickPub, loading }: ICalendarTimingItemProps, |
| 47 | ref: ForwardedRef<ICalendarTimingItemRef>, |
| 48 | ) => { |
| 49 | const { t } = useTransClient('account') |
| 50 | const isMobile = useIsMobile() |
| 51 | const lng = useGetClientLng() |
| 52 | |
| 53 | // arg.date 是当前格子的日期,Date 类型 |
| 54 | const today = new Date() |
| 55 | |
| 56 | // 去掉时分秒,只比较年月日 |
| 57 | const argDate = new Date(arg.date.getFullYear(), arg.date.getMonth(), arg.date.getDate()) |
| 58 | const nowDate = new Date(today.getFullYear(), today.getMonth(), today.getDate()) |
| 59 | |
| 60 | // [[小时,分钟]] [[4, 12]] |
| 61 | const [reservationsTimes, setReservationsTimes] = useState([]) |
| 62 | const [{ canDrop, isOver }, drop] = useDrop( |
| 63 | () => ({ |
| 64 | // 移动端禁用拖拽 |
| 65 | accept: isMobile ? 'none' : 'box', |
| 66 | drop: () => ({ |
| 67 | time: { |
| 68 | date: arg.date, |
| 69 | keepOriginalTime: true, |
| 70 | }, |
| 71 | }), |
| 72 | collect: monitor => ({ |
| 73 | isOver: monitor.isOver(), |
| 74 | canDrop: monitor.canDrop(), |
| 75 | }), |
| 76 | }), |
| 77 | [arg.date, isMobile], |
| 78 | ) |
| 79 | const [isMore, setIsMore] = useState(false) |
| 80 | const cellRef = useRef<HTMLDivElement | null>(null) |
| 81 | const dateStr = useMemo(() => dayjs(arg.date).format('YYYY-MM-DD'), [arg.date]) |
| 82 | const records = useCalendarTiming(state => state.recordMap.get(dateStr) ?? EMPTY_RECORDS) |
| 83 | const { showSolarFestivals, showSolarTerms } = useSystemStore( |
| 84 | useShallow(state => ({ |
| 85 | showSolarFestivals: state.calendarShowSolarFestivals, |
| 86 | showSolarTerms: state.calendarShowSolarTerms, |
| 87 | })), |
| 88 | ) |
| 89 | |
| 90 | const reservationsTimesLast = useMemo(() => { |
| 91 | return argDate >= nowDate ? reservationsTimes : [] |
| 92 | }, [reservationsTimes]) |
| 93 | |
| 94 | // 移动端默认显示更少的记录 |
| 95 | const maxRecords = isMobile ? 2 : 3 |
| 96 | |
| 97 | const recordsLast = useMemo(() => { |
| 98 | if (isMore) { |
| 99 | return records |
| 100 | } |
| 101 | else { |
| 102 | return records?.slice(0, maxRecords - reservationsTimesLast.length) |
| 103 | } |
| 104 | }, [isMore, records, reservationsTimesLast, maxRecords]) |
| 105 | |
| 106 | const festivals = useMemo(() => { |
| 107 | return filterCalendarFestivalEvents(getChinaCalendarEvents(arg.date, lng), { |
| 108 | showSolarFestivals, |
| 109 | showSolarTerms, |
| 110 | }) |
| 111 | }, [arg.date, lng, showSolarFestivals, showSolarTerms]) |
| 112 | const lunar = useMemo(() => getChinaCalendarLunarInfo(arg.date, lng), [arg.date, lng]) |
| 113 | const legalFestival = festivals.find(item => item.type === 'holiday' || item.type === 'workday') |
| 114 | const hasFestival = festivals.length > 0 |
| 115 | const isToday = argDate.getTime() === nowDate.getTime() |
| 116 | |
| 117 | // 进入视图时将"今天"尽量居中显示(仅在日历容器内滚动) |
| 118 | useEffect(() => { |
| 119 | if (argDate.getTime() === nowDate.getTime()) { |
| 120 | // 推迟到布局完成后再滚动 |
| 121 | setTimeout(() => { |
| 122 | const calendarContainer = document.getElementById('calendarTiming-calendar') |
| 123 | if (calendarContainer && cellRef.current) { |
| 124 | // 计算目标位置,使"今天"居中显示 |
| 125 | const containerRect = calendarContainer.getBoundingClientRect() |
| 126 | const cellRect = cellRef.current.getBoundingClientRect() |
| 127 | const scrollTop |
| 128 | = calendarContainer.scrollTop |
| 129 | + (cellRect.top - containerRect.top) |
| 130 | - containerRect.height / 2 |
| 131 | + cellRect.height / 2 |
| 132 | calendarContainer.scrollTo({ top: Math.max(0, scrollTop), behavior: 'smooth' }) |
| 133 | } |
| 134 | }, 100) |
| 135 | } |
| 136 | }, []) |
| 137 | |
| 138 | return ( |
| 139 | <div |
| 140 | ref={(node) => { |
| 141 | // 移动端不启用 drop |
| 142 | if (!isMobile && argDate >= nowDate) { |
| 143 | drop(node) |
| 144 | } |
| 145 | cellRef.current = node |
| 146 | }} |
| 147 | className={cn( |
| 148 | 'calendarTimingItem--js', |
| 149 | 'relative box-border p-1.5 md:p-2.5 flex flex-col font-semibold overflow-hidden', |
| 150 | 'min-h-[120px] md:min-h-[200px] h-full group', |
| 151 | 'transition-colors', |
| 152 | isToday && 'ring-1 ring-inset ring-primary/15', |
| 153 | isToday && !hasFestival && 'bg-gradient-to-br from-primary/5 via-background to-brand-cyan/5', |
| 154 | argDate < nowDate && !hasFestival && 'bg-muted/30', |
| 155 | // 只在桌面端显示拖拽高亮 |
| 156 | !isMobile && isOver && 'bg-accent/50', |
| 157 | )} |
| 158 | > |
| 159 | {/* 顶部:日期和添加按钮 */} |
| 160 | <div className="relative z-10 mb-2.5 flex items-start justify-between gap-1 group/top"> |
| 161 | <div className="flex min-w-0 flex-1 flex-col gap-1.5"> |
| 162 | <CalendarFestivalSummary |
| 163 | festivals={festivals} |
| 164 | date={arg.date} |
| 165 | lunar={lunar} |
| 166 | headerClassName="w-fit max-w-full flex-wrap px-1 py-0.5" |
| 167 | headerContent={( |
| 168 | <> |
| 169 | <span |
| 170 | className={cn( |
| 171 | 'inline-flex h-6 min-w-6 shrink-0 items-center justify-center rounded-full px-1.5 text-xs md:text-sm font-bold tabular-nums transition-colors', |
| 172 | isToday |
| 173 | ? 'bg-background text-primary ring-2 ring-primary/30 shadow-sm shadow-primary/15' |
| 174 | : 'text-foreground', |
| 175 | )} |
| 176 | > |
| 177 | {arg.date.getDate()} |
| 178 | </span> |
| 179 | <CalendarLunarText lunar={lunar} className="max-w-[4.5rem]" /> |
| 180 | {legalFestival && ( |
| 181 | <span |
| 182 | className={cn( |
| 183 | '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', |
| 184 | legalFestival.isWorkday |
| 185 | ? 'border border-border bg-background text-muted-foreground' |
| 186 | : 'bg-gradient-back text-gradient-foreground shadow-primary/15', |
| 187 | )} |
| 188 | title={t(legalFestival.statusTitleKey)} |
| 189 | aria-label={t(legalFestival.statusTitleKey)} |
| 190 | > |
| 191 | {t(legalFestival.statusKey)} |
| 192 | </span> |
| 193 | )} |
| 194 | </> |
| 195 | )} |
| 196 | className="max-w-full overflow-visible" |
| 197 | /> |
| 198 | </div> |
| 199 | |
| 200 | {/* 添加按钮:移动端始终可见,桌面端 hover 显示 */} |
| 201 | {argDate >= nowDate && ( |
| 202 | <Button |
| 203 | data-testid="calendar-cell-add-btn" |
| 204 | size="sm" |
| 205 | variant="ghost" |
| 206 | className={cn( |
| 207 | 'h-5 w-5 md:h-6 md:w-6 p-0 cursor-pointer', |
| 208 | 'md:opacity-0 md:group-hover:opacity-100 transition-opacity', |
| 209 | )} |
| 210 | onClick={() => { |
| 211 | const days = dayjs(arg.date) |
| 212 | const today = dayjs() |
| 213 | |
| 214 | if (today.date() === days.date()) { |
| 215 | onClickPub(today.add(10, 'minute').format()) |
| 216 | } |
| 217 | else { |
| 218 | onClickPub(days.format()) |
| 219 | } |
| 220 | }} |
| 221 | > |
| 222 | <Plus className="h-3 w-3 md:h-3.5 md:w-3.5" /> |
| 223 | </Button> |
| 224 | )} |
| 225 | </div> |
| 226 | |
| 227 | {/* 内容区域 */} |
| 228 | {loading ? ( |
| 229 | <div className="flex flex-col gap-1.5 md:gap-2"> |
| 230 | <Skeleton className="h-[28px] md:h-[34px] w-full rounded-md" /> |
| 231 | </div> |
| 232 | ) : ( |
| 233 | <div className="flex flex-col gap-1.5 md:gap-2"> |
| 234 | {/* 预约时间按钮 */} |
| 235 | {argDate >= nowDate |
| 236 | && reservationsTimesLast.map((v, i) => { |
| 237 | return ( |
| 238 | <Button |
| 239 | key={i} |
| 240 | size="sm" |
| 241 | variant="outline" |
| 242 | className="w-full h-[28px] md:h-[34px] text-xs group/btn relative overflow-hidden cursor-pointer" |
| 243 | onClick={() => { |
| 244 | const days = dayjs(arg.date).set('hour', v[0]).set('minute', v[1]) |
| 245 | onClickPub(days.format()) |
| 246 | }} |
| 247 | > |
| 248 | <span className="group-hover/btn:opacity-0 transition-opacity"> |
| 249 | {v[0]} |
| 250 | : |
| 251 | {v[1]} |
| 252 | {' '} |
| 253 | PM |
| 254 | </span> |
| 255 | <span className="absolute inset-0 flex items-center justify-center opacity-0 group-hover/btn:opacity-100 transition-opacity"> |
| 256 | {t('addPost')} |
| 257 | </span> |
| 258 | </Button> |
| 259 | ) |
| 260 | })} |
| 261 | |
| 262 | {/* 发布记录 */} |
| 263 | {recordsLast.map((v) => { |
| 264 | return ( |
| 265 | <div data-testid="calendar-cell-record" key={v.id + v.title + v.uid + v.updatedAt}> |
| 266 | {/* 移动端不显示拖拽层 */} |
| 267 | {!isMobile && <CustomDragLayer publishRecord={v} snapToGrid={false} />} |
| 268 | <CalendarRecord publishRecord={v} /> |
| 269 | </div> |
| 270 | ) |
| 271 | })} |
| 272 | |
| 273 | {/* 显示更多/收起按钮 */} |
| 274 | {records.length > maxRecords - reservationsTimesLast.length && ( |
| 275 | <Button |
| 276 | data-testid="calendar-cell-show-more" |
| 277 | variant="ghost" |
| 278 | className="w-full h-auto py-1.5 md:py-2 px-2 md:px-3 text-xs md:text-sm text-muted-foreground hover:text-foreground hover:bg-accent/50 transition-colors justify-start cursor-pointer" |
| 279 | onClick={() => { |
| 280 | setIsMore(!isMore) |
| 281 | }} |
| 282 | > |
| 283 | {isMore ? ( |
| 284 | <> |
| 285 | <ChevronUp className="mr-1.5 md:mr-2 h-3.5 w-3.5 md:h-4 md:w-4" /> |
| 286 | {t('calendar.hideMore')} |
| 287 | </> |
| 288 | ) : ( |
| 289 | <> |
| 290 | <ChevronDown className="mr-1.5 md:mr-2 h-3.5 w-3.5 md:h-4 md:w-4" /> |
| 291 | {records.length - recordsLast?.length} |
| 292 | {' '} |
| 293 | {t('calendar.showMore')} |
| 294 | </> |
| 295 | )} |
| 296 | </Button> |
| 297 | )} |
| 298 | </div> |
| 299 | )} |
| 300 | </div> |
| 301 | ) |
| 302 | }, |
| 303 | ), |
| 304 | ) |
| 305 | |
| 306 | export default CalendarTimingItem |
| 307 |