mirror of
https://github.com/langgenius/dify.git
synced 2025-12-04 06:47:05 +00:00
Signed-off-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: Stream <Stream_2@qq.com> Co-authored-by: lyzno1 <92089059+lyzno1@users.noreply.github.com> Co-authored-by: zhsama <torvalds@linux.do> Co-authored-by: Harry <xh001x@hotmail.com> Co-authored-by: lyzno1 <yuanyouhuilyz@gmail.com> Co-authored-by: yessenia <yessenia.contact@gmail.com> Co-authored-by: hjlarry <hjlarry@163.com> Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: WTW0313 <twwu@dify.ai> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
271 lines
8.6 KiB
TypeScript
271 lines
8.6 KiB
TypeScript
import React, { useCallback, useEffect, useRef, useState } from 'react'
|
|
import type { Dayjs } from 'dayjs'
|
|
import { Period } from '../types'
|
|
import type { TimePickerProps } from '../types'
|
|
import dayjs, {
|
|
getDateWithTimezone,
|
|
getHourIn12Hour,
|
|
isDayjsObject,
|
|
toDayjs,
|
|
} from '../utils/dayjs'
|
|
import {
|
|
PortalToFollowElem,
|
|
PortalToFollowElemContent,
|
|
PortalToFollowElemTrigger,
|
|
} from '@/app/components/base/portal-to-follow-elem'
|
|
import Footer from './footer'
|
|
import Options from './options'
|
|
import Header from './header'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { RiCloseCircleFill, RiTimeLine } from '@remixicon/react'
|
|
import cn from '@/utils/classnames'
|
|
import TimezoneLabel from '@/app/components/base/timezone-label'
|
|
|
|
const to24Hour = (hour12: string, period: Period) => {
|
|
const normalized = Number.parseInt(hour12, 10) % 12
|
|
return period === Period.PM ? normalized + 12 : normalized
|
|
}
|
|
|
|
const TimePicker = ({
|
|
value,
|
|
timezone,
|
|
placeholder,
|
|
onChange,
|
|
onClear,
|
|
renderTrigger,
|
|
title,
|
|
minuteFilter,
|
|
popupClassName,
|
|
notClearable = false,
|
|
triggerFullWidth = false,
|
|
showTimezone = false,
|
|
placement = 'bottom-start',
|
|
}: TimePickerProps) => {
|
|
const { t } = useTranslation()
|
|
const [isOpen, setIsOpen] = useState(false)
|
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
const isInitial = useRef(true)
|
|
|
|
// Initialize selectedTime
|
|
const [selectedTime, setSelectedTime] = useState(() => {
|
|
return toDayjs(value, { timezone })
|
|
})
|
|
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (containerRef.current && !containerRef.current.contains(event.target as Node))
|
|
setIsOpen(false)
|
|
}
|
|
document.addEventListener('mousedown', handleClickOutside)
|
|
return () => document.removeEventListener('mousedown', handleClickOutside)
|
|
}, [])
|
|
|
|
// Track previous values to avoid unnecessary updates
|
|
const prevValueRef = useRef(value)
|
|
const prevTimezoneRef = useRef(timezone)
|
|
|
|
useEffect(() => {
|
|
if (isInitial.current) {
|
|
isInitial.current = false
|
|
// Save initial values on first render
|
|
prevValueRef.current = value
|
|
prevTimezoneRef.current = timezone
|
|
return
|
|
}
|
|
|
|
// Only update when timezone changes but value doesn't
|
|
const valueChanged = prevValueRef.current !== value
|
|
const timezoneChanged = prevTimezoneRef.current !== timezone
|
|
|
|
// Update reference values
|
|
prevValueRef.current = value
|
|
prevTimezoneRef.current = timezone
|
|
|
|
// Skip if neither timezone changed nor value changed
|
|
if (!timezoneChanged && !valueChanged) return
|
|
|
|
if (value !== undefined && value !== null) {
|
|
const dayjsValue = toDayjs(value, { timezone })
|
|
if (!dayjsValue) return
|
|
|
|
setSelectedTime(dayjsValue)
|
|
|
|
if (timezoneChanged && !valueChanged)
|
|
onChange(dayjsValue)
|
|
return
|
|
}
|
|
|
|
setSelectedTime((prev) => {
|
|
if (!isDayjsObject(prev))
|
|
return undefined
|
|
return timezone ? getDateWithTimezone({ date: prev, timezone }) : prev
|
|
})
|
|
}, [timezone, value, onChange])
|
|
|
|
const handleClickTrigger = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
if (isOpen) {
|
|
setIsOpen(false)
|
|
return
|
|
}
|
|
setIsOpen(true)
|
|
|
|
if (value) {
|
|
const dayjsValue = toDayjs(value, { timezone })
|
|
const needsUpdate = dayjsValue && (
|
|
!selectedTime
|
|
|| !isDayjsObject(selectedTime)
|
|
|| !dayjsValue.isSame(selectedTime, 'minute')
|
|
)
|
|
if (needsUpdate) setSelectedTime(dayjsValue)
|
|
}
|
|
}
|
|
|
|
const handleClear = (e: React.MouseEvent) => {
|
|
e.stopPropagation()
|
|
setSelectedTime(undefined)
|
|
if (!isOpen)
|
|
onClear()
|
|
}
|
|
|
|
const handleTimeSelect = (hour: string, minute: string, period: Period) => {
|
|
const periodAdjustedHour = to24Hour(hour, period)
|
|
const nextMinute = Number.parseInt(minute, 10)
|
|
setSelectedTime((prev) => {
|
|
const reference = isDayjsObject(prev)
|
|
? prev
|
|
: (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('minute')
|
|
return reference
|
|
.set('hour', periodAdjustedHour)
|
|
.set('minute', nextMinute)
|
|
.set('second', 0)
|
|
.set('millisecond', 0)
|
|
})
|
|
}
|
|
|
|
const getSafeTimeObject = useCallback(() => {
|
|
if (isDayjsObject(selectedTime))
|
|
return selectedTime
|
|
return (timezone ? getDateWithTimezone({ timezone }) : dayjs()).startOf('day')
|
|
}, [selectedTime, timezone])
|
|
|
|
const handleSelectHour = useCallback((hour: string) => {
|
|
const time = getSafeTimeObject()
|
|
handleTimeSelect(hour, time.minute().toString().padStart(2, '0'), time.format('A') as Period)
|
|
}, [getSafeTimeObject])
|
|
|
|
const handleSelectMinute = useCallback((minute: string) => {
|
|
const time = getSafeTimeObject()
|
|
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), minute, time.format('A') as Period)
|
|
}, [getSafeTimeObject])
|
|
|
|
const handleSelectPeriod = useCallback((period: Period) => {
|
|
const time = getSafeTimeObject()
|
|
handleTimeSelect(getHourIn12Hour(time).toString().padStart(2, '0'), time.minute().toString().padStart(2, '0'), period)
|
|
}, [getSafeTimeObject])
|
|
|
|
const handleSelectCurrentTime = useCallback(() => {
|
|
const newDate = getDateWithTimezone({ timezone })
|
|
setSelectedTime(newDate)
|
|
onChange(newDate)
|
|
setIsOpen(false)
|
|
}, [timezone, onChange])
|
|
|
|
const handleConfirm = useCallback(() => {
|
|
const valueToEmit = isDayjsObject(selectedTime) ? selectedTime : undefined
|
|
onChange(valueToEmit)
|
|
setIsOpen(false)
|
|
}, [selectedTime, onChange])
|
|
|
|
const timeFormat = 'hh:mm A'
|
|
|
|
const formatTimeValue = useCallback((timeValue: string | Dayjs | undefined): string => {
|
|
if (!timeValue) return ''
|
|
|
|
const dayjsValue = toDayjs(timeValue, { timezone })
|
|
return dayjsValue?.format(timeFormat) || ''
|
|
}, [timezone])
|
|
|
|
const displayValue = formatTimeValue(value)
|
|
|
|
const placeholderDate = isOpen && isDayjsObject(selectedTime)
|
|
? selectedTime.format(timeFormat)
|
|
: (placeholder || t('time.defaultPlaceholder'))
|
|
|
|
const inputElem = (
|
|
<input
|
|
className='system-xs-regular flex-1 cursor-pointer select-none appearance-none truncate bg-transparent p-1
|
|
text-components-input-text-filled outline-none placeholder:text-components-input-text-placeholder'
|
|
readOnly
|
|
value={isOpen ? '' : displayValue}
|
|
placeholder={placeholderDate}
|
|
/>
|
|
)
|
|
return (
|
|
<PortalToFollowElem
|
|
open={isOpen}
|
|
onOpenChange={setIsOpen}
|
|
placement={placement}
|
|
>
|
|
<PortalToFollowElemTrigger className={triggerFullWidth ? '!block w-full' : undefined}>
|
|
{renderTrigger ? (renderTrigger({
|
|
inputElem,
|
|
onClick: handleClickTrigger,
|
|
isOpen,
|
|
})) : (
|
|
<div
|
|
className={cn(
|
|
'group flex cursor-pointer items-center gap-x-0.5 rounded-lg bg-components-input-bg-normal px-2 py-1 hover:bg-state-base-hover-alt',
|
|
triggerFullWidth ? 'w-full min-w-0' : 'w-[252px]',
|
|
)}
|
|
onClick={handleClickTrigger}
|
|
>
|
|
{inputElem}
|
|
{showTimezone && timezone && (
|
|
<TimezoneLabel timezone={timezone} inline className='shrink-0 select-none text-xs' />
|
|
)}
|
|
<RiTimeLine className={cn(
|
|
'h-4 w-4 shrink-0 text-text-quaternary',
|
|
isOpen ? 'text-text-secondary' : 'group-hover:text-text-secondary',
|
|
(displayValue || (isOpen && selectedTime)) && !notClearable && 'group-hover:hidden',
|
|
)} />
|
|
<RiCloseCircleFill
|
|
className={cn(
|
|
'hidden h-4 w-4 shrink-0 text-text-quaternary',
|
|
(displayValue || (isOpen && selectedTime)) && !notClearable && 'hover:text-text-secondary group-hover:inline-block',
|
|
)}
|
|
role='button'
|
|
aria-label={t('common.operation.clear')}
|
|
onClick={handleClear}
|
|
/>
|
|
</div>
|
|
)}
|
|
</PortalToFollowElemTrigger>
|
|
<PortalToFollowElemContent className={cn('z-50', popupClassName)}>
|
|
<div className='mt-1 w-[252px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg shadow-shadow-shadow-5'>
|
|
{/* Header */}
|
|
<Header title={title} />
|
|
|
|
{/* Time Options */}
|
|
<Options
|
|
selectedTime={selectedTime}
|
|
minuteFilter={minuteFilter}
|
|
handleSelectHour={handleSelectHour}
|
|
handleSelectMinute={handleSelectMinute}
|
|
handleSelectPeriod={handleSelectPeriod}
|
|
/>
|
|
|
|
{/* Footer */}
|
|
<Footer
|
|
handleSelectCurrentTime={handleSelectCurrentTime}
|
|
handleConfirm={handleConfirm}
|
|
/>
|
|
|
|
</div>
|
|
</PortalToFollowElemContent>
|
|
</PortalToFollowElem>
|
|
)
|
|
}
|
|
|
|
export default TimePicker
|