1- import { useEffect , useState , useCallback } from 'react' ;
1+ import { useEffect , useState , useCallback , useMemo } from 'react' ;
22import { useTranslation } from 'react-i18next' ;
33import type { TFunction } from 'i18next' ;
44import {
@@ -20,7 +20,12 @@ import {
2020 CheckCircle2 ,
2121 XCircle ,
2222 Trash2 ,
23+ Hand ,
2324} from 'lucide-react' ;
25+ import dayjs from 'dayjs' ;
26+ import customParseFormat from 'dayjs/plugin/customParseFormat' ;
27+
28+ dayjs . extend ( customParseFormat ) ;
2429import type { Cooldown , ProviderStats , ClientType } from '@/lib/transport/types' ;
2530import type { ProviderConfigItem } from '@/pages/client-routes/types' ;
2631import { useCooldownsContext } from '@/contexts/cooldowns-context' ;
@@ -96,6 +101,13 @@ const getReasonInfo = (t: TFunction) => ({
96101 color : 'text-muted-foreground' ,
97102 bgColor : 'bg-muted/50 border-border' ,
98103 } ,
104+ manual : {
105+ label : t ( 'provider.reasons.manual' ) ,
106+ description : t ( 'provider.reasons.manualDesc' , 'Provider 已被管理员手动冷冻' ) ,
107+ icon : Hand ,
108+ color : 'text-indigo-500 dark:text-indigo-400' ,
109+ bgColor : 'bg-indigo-500/10 dark:bg-indigo-500/15 border-indigo-500/30 dark:border-indigo-500/25' ,
110+ } ,
99111} ) ;
100112
101113// 格式化 Token 数量
@@ -130,6 +142,73 @@ function calcCacheRate(stats: ProviderStats): number {
130142 return ( cacheTotal / total ) * 100 ;
131143}
132144
145+ // 解析用户输入的时间字符串
146+ function parseTimeInput ( input : string ) : dayjs . Dayjs | null {
147+ const trimmed = input . trim ( ) . toLowerCase ( ) ;
148+ if ( ! trimmed ) return null ;
149+
150+ const now = dayjs ( ) ;
151+
152+ // 1. 相对时间格式: "5m", "30min", "2h", "1hour", "3d", "1day"
153+ const relativeMatch = trimmed . match ( / ^ ( \d + ) \s * ( m | m i n | m i n s | m i n u t e | m i n u t e s | h | h r | h r s | h o u r | h o u r s | d | d a y | d a y s ) $ / ) ;
154+ if ( relativeMatch ) {
155+ const value = parseInt ( relativeMatch [ 1 ] , 10 ) ;
156+ const unit = relativeMatch [ 2 ] ;
157+ if ( unit . startsWith ( 'm' ) ) {
158+ return now . add ( value , 'minute' ) ;
159+ } else if ( unit . startsWith ( 'h' ) ) {
160+ return now . add ( value , 'hour' ) ;
161+ } else if ( unit . startsWith ( 'd' ) ) {
162+ return now . add ( value , 'day' ) ;
163+ }
164+ }
165+
166+ // 2. 纯时间格式: "14:30", "2:30pm", "14:30:00"
167+ const timeOnlyMatch = trimmed . match ( / ^ ( \d { 1 , 2 } ) : ( \d { 2 } ) (?: : ( \d { 2 } ) ) ? (?: \s * ( a m | p m ) ) ? $ / ) ;
168+ if ( timeOnlyMatch ) {
169+ let hours = parseInt ( timeOnlyMatch [ 1 ] , 10 ) ;
170+ const minutes = parseInt ( timeOnlyMatch [ 2 ] , 10 ) ;
171+ const seconds = timeOnlyMatch [ 3 ] ? parseInt ( timeOnlyMatch [ 3 ] , 10 ) : 0 ;
172+ const ampm = timeOnlyMatch [ 4 ] ;
173+
174+ if ( ampm === 'pm' && hours < 12 ) hours += 12 ;
175+ if ( ampm === 'am' && hours === 12 ) hours = 0 ;
176+
177+ let result = now . hour ( hours ) . minute ( minutes ) . second ( seconds ) . millisecond ( 0 ) ;
178+ // 如果时间已过,设为明天
179+ if ( result . isBefore ( now ) || result . isSame ( now ) ) {
180+ result = result . add ( 1 , 'day' ) ;
181+ }
182+ return result ;
183+ }
184+
185+ // 3. 常见日期时间格式
186+ const formats = [
187+ 'YYYY-MM-DD HH:mm:ss' ,
188+ 'YYYY-MM-DD HH:mm' ,
189+ 'YYYY/MM/DD HH:mm:ss' ,
190+ 'YYYY/MM/DD HH:mm' ,
191+ 'MM-DD HH:mm' ,
192+ 'MM/DD HH:mm' ,
193+ 'DD HH:mm' ,
194+ ] ;
195+
196+ for ( const fmt of formats ) {
197+ const parsed = dayjs ( trimmed , fmt , true ) ;
198+ if ( parsed . isValid ( ) && parsed . isAfter ( now ) ) {
199+ return parsed ;
200+ }
201+ }
202+
203+ // 4. 尝试 dayjs 自动解析(ISO 格式等)
204+ const autoParsed = dayjs ( trimmed ) ;
205+ if ( autoParsed . isValid ( ) && autoParsed . isAfter ( now ) ) {
206+ return autoParsed ;
207+ }
208+
209+ return null ;
210+ }
211+
133212export function ProviderDetailsDialog ( {
134213 item,
135214 clientType,
@@ -146,7 +225,12 @@ export function ProviderDetailsDialog({
146225} : ProviderDetailsDialogProps ) {
147226 const { t, i18n } = useTranslation ( ) ;
148227 const REASON_INFO = getReasonInfo ( t ) ;
149- const { formatRemaining } = useCooldownsContext ( ) ;
228+ const { formatRemaining, setCooldown, isSettingCooldown } = useCooldownsContext ( ) ;
229+ const [ showCustomTime , setShowCustomTime ] = useState ( false ) ;
230+ const [ customTimeInput , setCustomTimeInput ] = useState ( '' ) ;
231+
232+ // 实时解析输入的时间
233+ const parsedTime = useMemo ( ( ) => parseTimeInput ( customTimeInput ) , [ customTimeInput ] ) ;
150234
151235 // 计算初始倒计时值
152236 const getInitialCountdown = useCallback ( ( ) => {
@@ -349,6 +433,101 @@ export function ProviderDetailsDialog({
349433 </ Button >
350434 ) }
351435
436+ { /* Manual Freeze Button (if not in cooldown) */ }
437+ { ! isInCooldown && ! showCustomTime && (
438+ < div className = "space-y-2" >
439+ < div className = "flex items-center gap-2 text-xs font-medium text-indigo-600 dark:text-indigo-400" >
440+ < Snowflake size = { 12 } />
441+ { t ( 'provider.manualFreeze' ) }
442+ </ div >
443+ < div className = "flex flex-wrap gap-1.5" >
444+ { [
445+ { label : '5m' , minutes : 5 } ,
446+ { label : '15m' , minutes : 15 } ,
447+ { label : '30m' , minutes : 30 } ,
448+ { label : '1h' , minutes : 60 } ,
449+ { label : '2h' , minutes : 120 } ,
450+ { label : '6h' , minutes : 360 } ,
451+ ] . map ( ( { label, minutes } ) => (
452+ < Button
453+ key = { label }
454+ disabled = { isSettingCooldown || isToggling }
455+ onClick = { ( ) => {
456+ const until = new Date ( Date . now ( ) + minutes * 60 * 1000 ) ;
457+ console . log ( 'Setting cooldown:' , provider . id , until . toISOString ( ) , clientType ) ;
458+ setCooldown ( provider . id , until . toISOString ( ) , clientType ) ;
459+ } }
460+ className = "px-3 py-1.5 text-xs rounded-lg border border-indigo-500/30 dark:border-indigo-500/25 bg-indigo-500/5 dark:bg-indigo-500/10 hover:bg-indigo-500/15 dark:hover:bg-indigo-500/20 text-indigo-600 dark:text-indigo-400 disabled:opacity-50"
461+ >
462+ { label }
463+ </ Button >
464+ ) ) }
465+ < Button
466+ disabled = { isSettingCooldown || isToggling }
467+ onClick = { ( ) => setShowCustomTime ( true ) }
468+ className = "px-3 py-1.5 text-xs rounded-lg border border-dashed border-indigo-500/30 dark:border-indigo-500/25 bg-transparent hover:bg-indigo-500/5 dark:hover:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 disabled:opacity-50"
469+ >
470+ { t ( 'provider.customTime' ) }
471+ </ Button >
472+ </ div >
473+ </ div >
474+ ) }
475+
476+ { /* Custom Time Input Dialog */ }
477+ { showCustomTime && (
478+ < div className = "rounded-xl border border-indigo-500/30 dark:border-indigo-500/25 bg-indigo-500/5 dark:bg-indigo-500/10 p-3 space-y-2" >
479+ < div className = "text-xs font-medium text-indigo-600 dark:text-indigo-400" >
480+ { t ( 'provider.freezeUntil' ) }
481+ </ div >
482+ < input
483+ type = "text"
484+ value = { customTimeInput }
485+ onChange = { ( e ) => setCustomTimeInput ( e . target . value ) }
486+ placeholder = "e.g. 30m, 2h, 14:30, 12:00:30, 2025-01-25 18:00"
487+ className = "w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono"
488+ autoFocus
489+ />
490+ { /* 实时解析预览 */ }
491+ < div className = "text-xs text-muted-foreground" >
492+ { customTimeInput ? (
493+ parsedTime ? (
494+ < span className = "text-emerald-600 dark:text-emerald-400" >
495+ → { parsedTime . format ( 'YYYY-MM-DD HH:mm:ss' ) }
496+ </ span >
497+ ) : (
498+ < span className = "text-rose-500" > { t ( 'provider.invalidTimeFormat' ) } </ span >
499+ )
500+ ) : (
501+ < span > { t ( 'provider.timeFormatHint' ) } </ span >
502+ ) }
503+ </ div >
504+ < div className = "flex gap-2" >
505+ < Button
506+ onClick = { ( ) => {
507+ setShowCustomTime ( false ) ;
508+ setCustomTimeInput ( '' ) ;
509+ } }
510+ className = "flex-1 rounded-lg border border-border bg-muted/50 px-3 py-1.5 text-xs"
511+ >
512+ { t ( 'common.cancel' ) }
513+ </ Button >
514+ < Button
515+ onClick = { ( ) => {
516+ if ( parsedTime ) {
517+ setCooldown ( provider . id , parsedTime . toISOString ( ) , clientType ) ;
518+ setShowCustomTime ( false ) ;
519+ setCustomTimeInput ( '' ) ;
520+ }
521+ } }
522+ disabled = { ! parsedTime }
523+ className = "flex-1 rounded-lg bg-indigo-500 text-white px-3 py-1.5 text-xs hover:bg-indigo-600 disabled:opacity-50"
524+ >
525+ { t ( 'provider.freezeConfirm' ) }
526+ </ Button >
527+ </ div >
528+ </ div >
529+ ) }
530+
352531 { /* Delete Button */ }
353532 { onDelete && (
354533 < Button
@@ -422,7 +601,7 @@ export function ProviderDetailsDialog({
422601 { liveCountdown }
423602 </ div >
424603 { ( ( ) => {
425- const untilDateStr = formatUntilTime ( cooldown . untilTime ) ;
604+ const untilDateStr = formatUntilTime ( cooldown . until ) ;
426605 return (
427606 < div className = "relative mt-2 text-[10px] text-teal-600/70 dark:text-teal-400/70 font-mono flex items-center gap-2" >
428607 < Clock size = { 10 } />
0 commit comments