| 1 | /** |
| 2 | * PublishDatePicker Component |
| 3 | * 发布日期选择器组件 - 支持立即发布和定时发布两种模式 |
| 4 | * - 点击左侧按钮打开日期选择器 |
| 5 | * - 底部 Now 按钮:选择立即发布 |
| 6 | * - 底部 Confirm 按钮:确认定时发布 |
| 7 | */ |
| 8 | |
| 9 | import type { Dayjs } from 'dayjs' |
| 10 | import type { ForwardedRef } from 'react' |
| 11 | import dayjs from 'dayjs' |
| 12 | import { Check, ChevronDown, Clock, Loader2, Send } from 'lucide-react' |
| 13 | import { forwardRef, memo, useEffect, useMemo, useState } from 'react' |
| 14 | import { useShallow } from 'zustand/react/shallow' |
| 15 | import { useTransClient } from '@/app/i18n/client' |
| 16 | import { usePublishDialog } from '@/components/PublishDialog/usePublishDialog' |
| 17 | import { Button } from '@/components/ui/button' |
| 18 | import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' |
| 19 | import { cn } from '@/utils/className' |
| 20 | import { DateTimePicker } from './DateTimePicker' |
| 21 | |
| 22 | export interface IPublishDatePickerRef {} |
| 23 | |
| 24 | export interface IPublishDatePickerProps { |
| 25 | loading: boolean |
| 26 | onClick: (pubTime?: string) => unknown | Promise<unknown> |
| 27 | // 是否为移动端 |
| 28 | isMobile?: boolean |
| 29 | value?: string |
| 30 | onValueChange?: (pubTime?: string) => void |
| 31 | inline?: boolean |
| 32 | showNowButton?: boolean |
| 33 | submitText?: string |
| 34 | minLeadMinutes?: number |
| 35 | } |
| 36 | |
| 37 | const PublishDatePicker = memo( |
| 38 | forwardRef( |
| 39 | ( |
| 40 | { |
| 41 | loading, |
| 42 | onClick, |
| 43 | isMobile, |
| 44 | value, |
| 45 | onValueChange, |
| 46 | inline = false, |
| 47 | showNowButton = true, |
| 48 | submitText, |
| 49 | minLeadMinutes = 20, |
| 50 | }: IPublishDatePickerProps, |
| 51 | ref: ForwardedRef<IPublishDatePickerRef>, |
| 52 | ) => { |
| 53 | const { t } = useTransClient('publish') |
| 54 | const [menuOpen, setMenuOpen] = useState(false) |
| 55 | const [tempDate, setTempDate] = useState<Dayjs | null>(null) |
| 56 | const { pubTime, setPubTime } = usePublishDialog( |
| 57 | useShallow(state => ({ |
| 58 | pubTime: state.pubTime, |
| 59 | setPubTime: state.setPubTime, |
| 60 | })), |
| 61 | ) |
| 62 | const currentPubTime = onValueChange ? value : pubTime |
| 63 | const updatePubTime = onValueChange ?? setPubTime |
| 64 | |
| 65 | const handleCalendarChange = (date: Dayjs) => { |
| 66 | setTempDate(date) |
| 67 | } |
| 68 | |
| 69 | // 点击 Now 按钮 - 选择立即发布模式 |
| 70 | const handleNowClick = (e: React.MouseEvent) => { |
| 71 | e.stopPropagation() |
| 72 | updatePubTime(undefined) |
| 73 | setMenuOpen(false) |
| 74 | } |
| 75 | |
| 76 | // 点击 Confirm 按钮 - 确认定时发布时间 |
| 77 | const handleScheduleConfirm = async (e: React.MouseEvent) => { |
| 78 | e.stopPropagation() |
| 79 | const nextPubTime = tempDate ? tempDate.format() : undefined |
| 80 | updatePubTime(nextPubTime) |
| 81 | setMenuOpen(false) |
| 82 | if (inline) { |
| 83 | await onClick(nextPubTime) |
| 84 | } |
| 85 | } |
| 86 | |
| 87 | // 禁用今天之前的日期 |
| 88 | const disabledDate = (current: dayjs.Dayjs) => { |
| 89 | return current && current < dayjs().startOf('day') |
| 90 | } |
| 91 | |
| 92 | // 禁用当前时间之前和当前时间20分钟内的时间(只精确到分钟,不考虑秒) |
| 93 | const disabledDateTime = (current: dayjs.Dayjs | null) => { |
| 94 | if (!current) |
| 95 | return {} |
| 96 | |
| 97 | const now = dayjs() |
| 98 | const minTime = now.add(minLeadMinutes, 'minute') |
| 99 | |
| 100 | // 只对今天限制 |
| 101 | if (current.isSame(now, 'day')) { |
| 102 | const minHour = minTime.hour() |
| 103 | const minMinute = minTime.minute() |
| 104 | |
| 105 | // 禁用小时 |
| 106 | const disabledHours = () => { |
| 107 | const hours: number[] = [] |
| 108 | for (let i = 0; i < 24; i++) { |
| 109 | if (i < minHour) |
| 110 | hours.push(i) |
| 111 | } |
| 112 | return hours |
| 113 | } |
| 114 | |
| 115 | // 禁用分钟 |
| 116 | const disabledMinutes = (selectedHour: number) => { |
| 117 | const minutes: number[] = [] |
| 118 | if (selectedHour === minHour) { |
| 119 | for (let i = 0; i < minMinute; i++) { |
| 120 | minutes.push(i) |
| 121 | } |
| 122 | } |
| 123 | else if (selectedHour < minHour) { |
| 124 | // 小时都禁用了,分钟全部禁用 |
| 125 | for (let i = 0; i < 60; i++) { |
| 126 | minutes.push(i) |
| 127 | } |
| 128 | } |
| 129 | return minutes |
| 130 | } |
| 131 | |
| 132 | return { |
| 133 | disabledHours, |
| 134 | disabledMinutes, |
| 135 | } |
| 136 | } |
| 137 | // 非今天不限制 |
| 138 | return {} |
| 139 | } |
| 140 | |
| 141 | const pubTimeValue = useMemo(() => { |
| 142 | if (currentPubTime) { |
| 143 | return dayjs(currentPubTime) |
| 144 | } |
| 145 | return null |
| 146 | }, [currentPubTime]) |
| 147 | |
| 148 | useEffect(() => { |
| 149 | if (inline) { |
| 150 | setTempDate(pubTimeValue || dayjs().add(30, 'minute')) |
| 151 | } |
| 152 | }, [inline, pubTimeValue]) |
| 153 | |
| 154 | // 当打开弹窗时,初始化临时日期 |
| 155 | const handleOpenChange = (open: boolean) => { |
| 156 | if (open) { |
| 157 | // 如果已有定时时间,使用它;否则使用当前时间+30分钟作为默认 |
| 158 | setTempDate(pubTimeValue || dayjs().add(30, 'minute')) |
| 159 | } |
| 160 | setMenuOpen(open) |
| 161 | } |
| 162 | |
| 163 | // 日期选择器内容 |
| 164 | const datePickerContent = ( |
| 165 | <div className={cn(isMobile ? 'w-full' : 'w-[400px]')}> |
| 166 | <DateTimePicker |
| 167 | value={tempDate} |
| 168 | onChange={handleCalendarChange} |
| 169 | disabledDate={disabledDate} |
| 170 | disabledTime={disabledDateTime} |
| 171 | isMobile={isMobile} |
| 172 | /> |
| 173 | |
| 174 | <div className="flex items-center justify-between py-2.5 px-4 border-t border-border"> |
| 175 | {showNowButton ? ( |
| 176 | <Button |
| 177 | variant="ghost" |
| 178 | size="sm" |
| 179 | onClick={handleNowClick} |
| 180 | className="gap-2 cursor-pointer" |
| 181 | data-testid="publish-now-button" |
| 182 | > |
| 183 | <Send className="w-4 h-4" /> |
| 184 | {t('buttons.publishNow')} |
| 185 | </Button> |
| 186 | ) : <span />} |
| 187 | |
| 188 | <Button |
| 189 | variant="ghost" |
| 190 | size="sm" |
| 191 | onClick={handleScheduleConfirm} |
| 192 | className="gap-2 cursor-pointer" |
| 193 | disabled={loading || !tempDate} |
| 194 | data-testid="publish-schedule-confirm-button" |
| 195 | > |
| 196 | {loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Check className="w-4 h-4" />} |
| 197 | {submitText ?? t('buttons.confirm')} |
| 198 | </Button> |
| 199 | </div> |
| 200 | </div> |
| 201 | ) |
| 202 | |
| 203 | if (inline) { |
| 204 | return ( |
| 205 | <div className={cn('w-full', isMobile && 'w-full')} data-testid="publish-date-picker"> |
| 206 | {datePickerContent} |
| 207 | </div> |
| 208 | ) |
| 209 | } |
| 210 | |
| 211 | return ( |
| 212 | <div className={cn('flex gap-0', isMobile && 'w-full')} data-testid="publish-date-picker"> |
| 213 | <Popover open={menuOpen} onOpenChange={handleOpenChange}> |
| 214 | <PopoverTrigger asChild> |
| 215 | <Button |
| 216 | variant="outline" |
| 217 | className={cn( |
| 218 | 'flex items-center gap-2.5 rounded-r-none border-r-0 h-10 px-4 cursor-pointer', |
| 219 | isMobile && 'flex-1', |
| 220 | )} |
| 221 | onClick={() => { |
| 222 | setMenuOpen(true) |
| 223 | }} |
| 224 | data-testid="publish-schedule-trigger" |
| 225 | > |
| 226 | {pubTimeValue ? <Clock className="w-4 h-4" /> : <Send className="w-4 h-4" />} |
| 227 | <span className="flex-1 text-left"> |
| 228 | {pubTimeValue ? pubTimeValue.format('YYYY-MM-DD HH:mm') : t('buttons.publishNow')} |
| 229 | </span> |
| 230 | <ChevronDown className="w-4 h-4" /> |
| 231 | </Button> |
| 232 | </PopoverTrigger> |
| 233 | <PopoverContent |
| 234 | className={cn('w-auto p-0', isMobile && 'w-[calc(100vw-32px)]')} |
| 235 | align="start" |
| 236 | allowInnerScroll |
| 237 | > |
| 238 | {datePickerContent} |
| 239 | </PopoverContent> |
| 240 | </Popover> |
| 241 | <Button |
| 242 | className={cn('rounded-l-none h-10 px-6 cursor-pointer', isMobile && 'shrink-0')} |
| 243 | disabled={loading} |
| 244 | onClick={() => onClick(currentPubTime)} |
| 245 | data-testid="publish-submit-btn" |
| 246 | > |
| 247 | {loading && <Loader2 className="-ml-1 mr-2 h-4 w-4 animate-spin" />} |
| 248 | {t('publish')} |
| 249 | </Button> |
| 250 | </div> |
| 251 | ) |
| 252 | }, |
| 253 | ), |
| 254 | ) |
| 255 | |
| 256 | PublishDatePicker.displayName = 'PublishDatePicker' |
| 257 | |
| 258 | export default PublishDatePicker |
| 259 |