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