+
{actions}
{children}
diff --git a/web/src/components/provider-details-dialog.tsx b/web/src/components/provider-details-dialog.tsx
index c7de551e..16d9eff0 100644
--- a/web/src/components/provider-details-dialog.tsx
+++ b/web/src/components/provider-details-dialog.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState, useCallback } from 'react';
+import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import {
@@ -20,10 +20,15 @@ import {
CheckCircle2,
XCircle,
Trash2,
+ Hand,
} from 'lucide-react';
+import dayjs from 'dayjs';
+import customParseFormat from 'dayjs/plugin/customParseFormat';
+
+dayjs.extend(customParseFormat);
import type { Cooldown, ProviderStats, ClientType } from '@/lib/transport/types';
import type { ProviderConfigItem } from '@/pages/client-routes/types';
-import { useCooldowns } from '@/hooks/use-cooldowns';
+import { useCooldownsContext } from '@/contexts/cooldowns-context';
import { Button, Switch } from '@/components/ui';
import { getProviderColor, type ProviderType } from '@/lib/theme';
import { cn } from '@/lib/utils';
@@ -96,6 +101,14 @@ const getReasonInfo = (t: TFunction) => ({
color: 'text-muted-foreground',
bgColor: 'bg-muted/50 border-border',
},
+ manual: {
+ label: t('provider.reasons.manual'),
+ description: t('provider.reasons.manualDesc', 'Provider 已被管理员手动冷冻'),
+ icon: Hand,
+ color: 'text-indigo-500 dark:text-indigo-400',
+ bgColor:
+ 'bg-indigo-500/10 dark:bg-indigo-500/15 border-indigo-500/30 dark:border-indigo-500/25',
+ },
});
// 格式化 Token 数量
@@ -109,16 +122,17 @@ function formatTokens(count: number): string {
return count.toString();
}
-// 格式化成本 (微美元 → 美元)
-function formatCost(microUsd: number): string {
- const usd = microUsd / 1_000_000;
+// 格式化成本 (纳美元 → 美元,向下取整到 6 位)
+function formatCost(nanoUsd: number): string {
+ // 向下取整到 6 位小数 (microUSD 精度)
+ const usd = Math.floor(nanoUsd / 1000) / 1_000_000;
if (usd >= 1) {
return `$${usd.toFixed(2)}`;
}
if (usd >= 0.01) {
return `$${usd.toFixed(3)}`;
}
- return `$${usd.toFixed(4)}`;
+ return `$${usd.toFixed(6).replace(/\.?0+$/, '')}`;
}
// 计算缓存利用率
@@ -129,6 +143,75 @@ function calcCacheRate(stats: ProviderStats): number {
return (cacheTotal / total) * 100;
}
+// 解析用户输入的时间字符串
+function parseTimeInput(input: string): dayjs.Dayjs | null {
+ const trimmed = input.trim().toLowerCase();
+ if (!trimmed) return null;
+
+ const now = dayjs();
+
+ // 1. 相对时间格式: "5m", "30min", "2h", "1hour", "3d", "1day"
+ const relativeMatch = trimmed.match(
+ /^(\d+)\s*(m|min|mins|minute|minutes|h|hr|hrs|hour|hours|d|day|days)$/,
+ );
+ if (relativeMatch) {
+ const value = parseInt(relativeMatch[1], 10);
+ const unit = relativeMatch[2];
+ if (unit.startsWith('m')) {
+ return now.add(value, 'minute');
+ } else if (unit.startsWith('h')) {
+ return now.add(value, 'hour');
+ } else if (unit.startsWith('d')) {
+ return now.add(value, 'day');
+ }
+ }
+
+ // 2. 纯时间格式: "14:30", "2:30pm", "14:30:00"
+ const timeOnlyMatch = trimmed.match(/^(\d{1,2}):(\d{2})(?::(\d{2}))?(?:\s*(am|pm))?$/);
+ if (timeOnlyMatch) {
+ let hours = parseInt(timeOnlyMatch[1], 10);
+ const minutes = parseInt(timeOnlyMatch[2], 10);
+ const seconds = timeOnlyMatch[3] ? parseInt(timeOnlyMatch[3], 10) : 0;
+ const ampm = timeOnlyMatch[4];
+
+ if (ampm === 'pm' && hours < 12) hours += 12;
+ if (ampm === 'am' && hours === 12) hours = 0;
+
+ let result = now.hour(hours).minute(minutes).second(seconds).millisecond(0);
+ // 如果时间已过,设为明天
+ if (result.isBefore(now) || result.isSame(now)) {
+ result = result.add(1, 'day');
+ }
+ return result;
+ }
+
+ // 3. 常见日期时间格式
+ const formats = [
+ 'YYYY-MM-DD HH:mm:ss',
+ 'YYYY-MM-DD HH:mm',
+ 'YYYY/MM/DD HH:mm:ss',
+ 'YYYY/MM/DD HH:mm',
+ 'MM-DD HH:mm',
+ 'MM/DD HH:mm',
+ 'DD HH:mm',
+ ];
+
+ for (const fmt of formats) {
+ const parsed = dayjs(trimmed, fmt, true);
+ if (parsed.isValid() && parsed.isAfter(now)) {
+ return parsed;
+ }
+ }
+
+ // 4. 尝试 dayjs 自动解析(ISO 格式等)
+ const autoParsed = dayjs(trimmed);
+ if (autoParsed.isValid() && autoParsed.isAfter(now)) {
+ return autoParsed;
+ }
+
+ return null;
+}
+
export function ProviderDetailsDialog({
item,
clientType,
@@ -145,7 +228,12 @@ export function ProviderDetailsDialog({
}: ProviderDetailsDialogProps) {
const { t, i18n } = useTranslation();
const REASON_INFO = getReasonInfo(t);
- const { formatRemaining } = useCooldowns();
+ const { formatRemaining, setCooldown, isSettingCooldown } = useCooldownsContext();
+ const [showCustomTime, setShowCustomTime] = useState(false);
+ const [customTimeInput, setCustomTimeInput] = useState('');
+
+ // 实时解析输入的时间
+ const parsedTime = useMemo(() => parseTimeInput(customTimeInput), [customTimeInput]);
// 计算初始倒计时值
const getInitialCountdown = useCallback(() => {
@@ -348,6 +436,106 @@ export function ProviderDetailsDialog({
)}
+ {/* Manual Freeze Button (if not in cooldown) */}
+ {!isInCooldown && !showCustomTime && (
+
+
+
+ {t('provider.manualFreeze')}
+
+
+ {[
+ { label: '5m', minutes: 5 },
+ { label: '15m', minutes: 15 },
+ { label: '30m', minutes: 30 },
+ { label: '1h', minutes: 60 },
+ { label: '2h', minutes: 120 },
+ { label: '6h', minutes: 360 },
+ ].map(({ label, minutes }) => (
+
+ ))}
+
+
+
+ )}
+
+ {/* Custom Time Input Dialog */}
+ {showCustomTime && (
+
+
+ {t('provider.freezeUntil')}
+
+
setCustomTimeInput(e.target.value)}
+ placeholder="e.g. 30m, 2h, 14:30, 12:00:30, 2025-01-25 18:00"
+ className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono"
+ autoFocus
+ />
+ {/* 实时解析预览 */}
+
+ {customTimeInput ? (
+ parsedTime ? (
+
+ → {parsedTime.format('YYYY-MM-DD HH:mm:ss')}
+
+ ) : (
+ {t('provider.invalidTimeFormat')}
+ )
+ ) : (
+ {t('provider.timeFormatHint')}
+ )}
+
+
+
+
+
+
+ )}
+
{/* Delete Button */}
{onDelete && (