返回 AiToEarn
CalendarToolbar.tsx
根目录 / project / aitoearn-web / src / app / [lng] / accounts / components / CalendarTiming / CalendarToolbar.tsx
1 /**
2 * CalendarToolbar 组件
3 *
4 * 功能描述: 日历工具栏 - 包含导航按钮、日期显示、视图切换
5 * - 上月/下月(或上周/下周)按钮
6 * - 当前月份/周范围显示
7 * - 月/周视图切换按钮
8 * - 今天按钮
9 */
10
11 'use client'
12
13 import type { CalendarViewType } from '@/store/system'
14 import dayjs from 'dayjs'
15 import { CalendarDays, ChevronLeft, ChevronRight, Grid3X3 } from 'lucide-react'
16 import { memo, useMemo } from 'react'
17 import { useShallow } from 'zustand/react/shallow'
18 import { useTransClient } from '@/app/i18n/client'
19 import { getDayjsLocale } from '@/app/i18n/languageConfig'
20 import { Button } from '@/components/ui/button'
21 import { useGetClientLng } from '@/hooks/useSystem'
22 import { useSystemStore } from '@/store/system'
23 import { cn } from '@/utils/className'
24 import 'dayjs/locale/zh-cn'
25 import 'dayjs/locale/en'
26 import 'dayjs/locale/de'
27 import 'dayjs/locale/fr'
28 import 'dayjs/locale/ja'
29 import 'dayjs/locale/ko'
30
31 export interface ICalendarToolbarProps {
32 currentDate: Date
33 viewType: CalendarViewType
34 onPrev: () => void
35 onNext: () => void
36 onToday: () => void
37 onViewTypeChange: (type: CalendarViewType) => void
38 }
39
40 const CalendarToolbar = memo<ICalendarToolbarProps>(
41 ({ currentDate, viewType, onPrev, onNext, onToday, onViewTypeChange }) => {
42 const { t } = useTransClient('account')
43 const lng = useGetClientLng()
44 const {
45 showSolarFestivals,
46 showSolarTerms,
47 setShowSolarFestivals,
48 setShowSolarTerms,
49 } = useSystemStore(
50 useShallow(state => ({
51 showSolarFestivals: state.calendarShowSolarFestivals,
52 showSolarTerms: state.calendarShowSolarTerms,
53 setShowSolarFestivals: state.setCalendarShowSolarFestivals,
54 setShowSolarTerms: state.setCalendarShowSolarTerms,
55 })),
56 )
57
58 // 根据语言设置 dayjs locale 并格式化日期
59 const formattedDate = useMemo(() => {
60 const locale = getDayjsLocale(lng)
61 dayjs.locale(locale)
62
63 if (viewType === 'month') {
64 return dayjs(currentDate).format('MMMM YYYY')
65 }
66
67 // 周视图:显示周范围
68 const start = dayjs(currentDate).startOf('week')
69 const end = start.add(6, 'day')
70
71 // 根据语言选择格式
72 switch (lng) {
73 case 'zh-CN':
74 case 'ja':
75 // 中文/日语: "1月12日 - 18日, 2026"
76 if (start.month() === end.month()) {
77 return `${start.format('M月D日')} - ${end.format('D日')}, ${end.format('YYYY')}`
78 }
79 else if (start.year() === end.year()) {
80 return `${start.format('M月D日')} - ${end.format('M月D日')}, ${end.format('YYYY')}`
81 }
82 else {
83 return `${start.format('YYYY年M月D日')} - ${end.format('YYYY年M月D日')}`
84 }
85
86 case 'ko':
87 // 韩语: "1월 12일 - 18일, 2026"
88 if (start.month() === end.month()) {
89 return `${start.format('M월 D일')} - ${end.format('D일')}, ${end.format('YYYY')}`
90 }
91 else if (start.year() === end.year()) {
92 return `${start.format('M월 D일')} - ${end.format('M월 D일')}, ${end.format('YYYY')}`
93 }
94 else {
95 return `${start.format('YYYY년 M월 D일')} - ${end.format('YYYY년 M월 D일')}`
96 }
97
98 case 'de':
99 // 德语: "12. - 18. Jan 2026"
100 if (start.month() === end.month()) {
101 return `${start.format('D.')} - ${end.format('D. MMM YYYY')}`
102 }
103 else if (start.year() === end.year()) {
104 return `${start.format('D. MMM')} - ${end.format('D. MMM YYYY')}`
105 }
106 else {
107 return `${start.format('D. MMM YYYY')} - ${end.format('D. MMM YYYY')}`
108 }
109
110 case 'fr':
111 // 法语: "12 - 18 janv. 2026"
112 if (start.month() === end.month()) {
113 return `${start.format('D')} - ${end.format('D MMM YYYY')}`
114 }
115 else if (start.year() === end.year()) {
116 return `${start.format('D MMM')} - ${end.format('D MMM YYYY')}`
117 }
118 else {
119 return `${start.format('D MMM YYYY')} - ${end.format('D MMM YYYY')}`
120 }
121
122 default:
123 // 英语等: "Jan 12 - 18, 2026"
124 if (start.month() === end.month()) {
125 return `${start.format('MMM D')} - ${end.format('D, YYYY')}`
126 }
127 else if (start.year() === end.year()) {
128 return `${start.format('MMM D')} - ${end.format('MMM D, YYYY')}`
129 }
130 else {
131 return `${start.format('MMM D, YYYY')} - ${end.format('MMM D, YYYY')}`
132 }
133 }
134 }, [currentDate, lng, viewType])
135
136 return (
137 <div className="h-12 md:h-14 flex items-center justify-between px-4 md:px-6 py-2 md:py-3 border-b bg-background shrink-0">
138 {/* 导航控制 */}
139 <div className="flex items-center gap-1 md:gap-3">
140 <Button
141 data-testid="calendar-prev-btn"
142 variant="ghost"
143 size="icon"
144 onClick={onPrev}
145 className="h-7 w-7 md:h-8 md:w-8 cursor-pointer"
146 >
147 <ChevronLeft className="h-4 w-4" />
148 </Button>
149 <Button
150 data-testid="calendar-next-btn"
151 variant="ghost"
152 size="icon"
153 onClick={onNext}
154 className="h-7 w-7 md:h-8 md:w-8 cursor-pointer"
155 >
156 <ChevronRight className="h-4 w-4" />
157 </Button>
158 <div data-testid="calendar-date-title" className="text-sm md:text-base font-semibold text-foreground min-w-[100px] md:min-w-[180px] text-center">
159 {formattedDate}
160 </div>
161 </div>
162
163 {/* 右侧:视图切换和今天按钮 */}
164 <div className="flex items-center gap-2">
165 <div
166 className="hidden items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5 md:flex"
167 aria-label={`${t('calendar.filters.solarFestivals')} / ${t('calendar.filters.solarTerms')}`}
168 >
169 <Button
170 data-testid="calendar-toggle-solar-festival"
171 type="button"
172 variant="ghost"
173 size="sm"
174 className={cn(
175 'h-7 rounded-md px-2.5 text-xs font-medium cursor-pointer shadow-none',
176 showSolarFestivals
177 ? 'bg-background text-foreground shadow-sm hover:bg-background'
178 : 'text-muted-foreground hover:bg-background/60 hover:text-foreground',
179 )}
180 aria-pressed={showSolarFestivals}
181 onClick={() => setShowSolarFestivals(!showSolarFestivals)}
182 >
183 {t('calendar.filters.solarFestivals')}
184 </Button>
185 <Button
186 data-testid="calendar-toggle-solar-term"
187 type="button"
188 variant="ghost"
189 size="sm"
190 className={cn(
191 'h-7 rounded-md px-2.5 text-xs font-medium cursor-pointer shadow-none',
192 showSolarTerms
193 ? 'bg-background text-foreground shadow-sm hover:bg-background'
194 : 'text-muted-foreground hover:bg-background/60 hover:text-foreground',
195 )}
196 aria-pressed={showSolarTerms}
197 onClick={() => setShowSolarTerms(!showSolarTerms)}
198 >
199 {t('calendar.filters.solarTerms')}
200 </Button>
201 </div>
202
203 {/* 视图切换按钮 */}
204 <div className="flex items-center border rounded-md">
205 <Button
206 data-testid="calendar-view-week"
207 variant="ghost"
208 size="icon"
209 className={cn(
210 'h-7 w-7 md:h-8 md:w-8 rounded-r-none cursor-pointer',
211 viewType === 'week' && 'bg-accent',
212 )}
213 onClick={() => onViewTypeChange('week')}
214 title={t('calendar.weekView')}
215 >
216 <CalendarDays className="h-4 w-4" />
217 </Button>
218 <Button
219 data-testid="calendar-view-month"
220 variant="ghost"
221 size="icon"
222 className={cn(
223 'h-7 w-7 md:h-8 md:w-8 rounded-l-none cursor-pointer',
224 viewType === 'month' && 'bg-accent',
225 )}
226 onClick={() => onViewTypeChange('month')}
227 title={t('calendar.monthView')}
228 >
229 <Grid3X3 className="h-4 w-4" />
230 </Button>
231 </div>
232
233 {/* 今天按钮 */}
234 <Button
235 data-testid="calendar-today-btn"
236 variant="outline"
237 onClick={onToday}
238 size="sm"
239 className="h-7 md:h-8 text-xs md:text-sm cursor-pointer"
240 >
241 {t('today')}
242 </Button>
243 </div>
244 </div>
245 )
246 },
247 )
248
249 CalendarToolbar.displayName = 'CalendarToolbar'
250
251 export default CalendarToolbar
252
252 lines Plain Text