返回 AiToEarn
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
307 lines Plain Text